mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
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>
This commit is contained in:
45 files changed
+3593
-79
No files matched your search
@@ -138,6 +138,7 @@ jobs:
|
||||
target:
|
||||
- { crate: psd, name: parse }
|
||||
- { crate: psd, name: descriptor }
|
||||
- { crate: psd, name: image_source_data }
|
||||
- { crate: codecs, name: decode_any }
|
||||
- { crate: codecs, name: decode_png }
|
||||
- { crate: codecs, name: decode_jpeg }
|
||||
|
||||
@@ -198,7 +198,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
|
||||
@@ -144,6 +144,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(move || {
|
||||
|
||||
@@ -153,6 +153,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
|
||||
|
||||
## Layered TIFF
|
||||
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling.
|
||||
|
||||
Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, DPI, and animation behavior are defined by `crates/codecs/src/format.rs`. Do not infer fidelity from the filename extension alone.
|
||||
|
||||
## Exporting documents
|
||||
|
||||
@@ -208,7 +208,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
|
||||
img.icc = meta.icc;
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, text: Vec::new() };
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
|
||||
if end == DataEnd::Truncated {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
//! CMYK with optional (unassociated) alpha, ICC (34675), XMP (700), DPI and
|
||||
//! a few ASCII text tags. Only the first IFD is read; further pages are counted and reported
|
||||
//! as a [`DecodeWarning::MorePages`].
|
||||
//!
|
||||
//! Photoshop's private tags are carried as opaque bytes in [`Metadata`]: 34377 (image
|
||||
//! resources) and 37724 (`ImageSourceData`, the layers of a layered TIFF). They are read with
|
||||
//! a bounds-checked walk of the first directory ([`photoshop_tags`]) rather than through the
|
||||
//! decoder, which would materialize a multi-megabyte layer block as one `Value` per byte, and
|
||||
//! written back as BYTE / UNDEFINED tags. The encoder writes files in the native byte order
|
||||
//! (see [`writes_little_endian`]), which the layer data must match.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
@@ -19,6 +26,87 @@ use crate::options::{EncodeOptions, Limits, TiffCompression};
|
||||
|
||||
const F: Format = Format::Tiff;
|
||||
const TAG_XMP: u16 = 700;
|
||||
/// Photoshop image resources (`8BIM` blocks).
|
||||
const TAG_PHOTOSHOP: u16 = 34377;
|
||||
/// Photoshop `ImageSourceData`: the layers of a layered TIFF.
|
||||
const TAG_IMAGE_SOURCE_DATA: u16 = 37724;
|
||||
|
||||
/// `true` when [`encode`] writes little-endian (`II`) files: the `tiff` encoder writes the
|
||||
/// running target's byte order, and every target PhotoCraft builds for is little-endian.
|
||||
/// Byte-order-sensitive payloads (Photoshop layer data) must be produced to match.
|
||||
pub fn writes_little_endian() -> bool {
|
||||
cfg!(target_endian = "little")
|
||||
}
|
||||
|
||||
/// `(34377 image resources, 37724 image source data)` payloads, each absent when the tag is.
|
||||
pub type PhotoshopTags<'a> = (Option<&'a [u8]>, Option<&'a [u8]>);
|
||||
|
||||
/// The raw payloads of the Photoshop private tags in the first directory: `(34377 image
|
||||
/// resources, 37724 image source data)`. Classic and BigTIFF, either byte order; only
|
||||
/// header bytes and the two payloads are touched, and nothing is allocated. Anything malformed
|
||||
/// reads as "absent".
|
||||
pub fn photoshop_tags(b: &[u8]) -> PhotoshopTags<'_> {
|
||||
fn walk(b: &[u8]) -> Option<PhotoshopTags<'_>> {
|
||||
let le = match b.get(0..2)? {
|
||||
b"II" => true,
|
||||
b"MM" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let u16_at = |o: u64| -> Option<u16> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s = b.get(o..o.checked_add(2)?)?;
|
||||
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
|
||||
};
|
||||
let u32_at = |o: u64| -> Option<u32> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s: [u8; 4] = b.get(o..o.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) })
|
||||
};
|
||||
let u64_at = |o: u64| -> Option<u64> {
|
||||
let o = usize::try_from(o).ok()?;
|
||||
let s: [u8; 8] = b.get(o..o.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(if le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
|
||||
};
|
||||
let (big, ifd) = match u16_at(2)? {
|
||||
42 => (false, u64::from(u32_at(4)?)),
|
||||
43 => (true, u64_at(8)?),
|
||||
_ => return None,
|
||||
};
|
||||
let n = if big { u64_at(ifd)? } else { u64::from(u16_at(ifd)?) }.min(4096);
|
||||
let (entry, first, inline) = if big { (20u64, ifd.checked_add(8)?, 8u64) } else { (12, ifd.checked_add(2)?, 4) };
|
||||
let mut resources = None;
|
||||
let mut layers = None;
|
||||
for i in 0..n {
|
||||
let e = first.checked_add(i.checked_mul(entry)?)?;
|
||||
let tag = u16_at(e)?;
|
||||
if tag != TAG_PHOTOSHOP && tag != TAG_IMAGE_SOURCE_DATA {
|
||||
continue;
|
||||
}
|
||||
// BYTE, ASCII, SBYTE or UNDEFINED: one byte per element.
|
||||
if !matches!(u16_at(e + 2)?, 1 | 2 | 6 | 7) {
|
||||
continue;
|
||||
}
|
||||
let count = if big { u64_at(e + 4)? } else { u64::from(u32_at(e + 4)?) };
|
||||
let value_at = e + if big { 12 } else { 8 };
|
||||
let start = if count <= inline {
|
||||
value_at
|
||||
} else if big {
|
||||
u64_at(value_at)?
|
||||
} else {
|
||||
u64::from(u32_at(value_at)?)
|
||||
};
|
||||
let start = usize::try_from(start).ok()?;
|
||||
let data = b.get(start..start.checked_add(usize::try_from(count).ok()?)?)?;
|
||||
if tag == TAG_PHOTOSHOP {
|
||||
resources = Some(data);
|
||||
} else {
|
||||
layers = Some(data);
|
||||
}
|
||||
}
|
||||
Some((resources, layers))
|
||||
}
|
||||
walk(b).unwrap_or((None, None))
|
||||
}
|
||||
|
||||
/// ASCII tags mapped to `Metadata::text` keys.
|
||||
const TEXT_TAGS: &[(Tag, &str)] = &[
|
||||
@@ -215,6 +303,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
}
|
||||
}
|
||||
let (resources, layers) = photoshop_tags(bytes);
|
||||
meta.photoshop_resources = resources.map(<[u8]>::to_vec);
|
||||
meta.photoshop_layers = layers.map(<[u8]>::to_vec);
|
||||
img.meta = meta;
|
||||
img.warnings.extend(more_pages(&mut dec));
|
||||
Ok(img)
|
||||
@@ -342,6 +433,8 @@ struct TagSet<'a> {
|
||||
dpi: Option<(f32, f32)>,
|
||||
text: Vec<(Tag, &'a str)>,
|
||||
alpha: bool,
|
||||
photoshop_resources: Option<&'a [u8]>,
|
||||
photoshop_layers: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
fn write_one<C>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()>
|
||||
@@ -369,6 +462,12 @@ where
|
||||
for (tag, s) in &tags.text {
|
||||
d.write_tag(*tag, *s)?;
|
||||
}
|
||||
if let Some(r) = tags.photoshop_resources {
|
||||
d.write_tag(Tag::Unknown(TAG_PHOTOSHOP), r)?;
|
||||
}
|
||||
if let Some(l) = tags.photoshop_layers {
|
||||
d.write_tag(Tag::Unknown(TAG_IMAGE_SOURCE_DATA), Undefined(l))?;
|
||||
}
|
||||
im.write_data(data)
|
||||
}
|
||||
|
||||
@@ -404,6 +503,9 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
|
||||
text,
|
||||
alpha: img.layout().has_alpha(),
|
||||
// Document content, not metadata: written whenever present.
|
||||
photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()),
|
||||
photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()),
|
||||
};
|
||||
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
|
||||
@@ -121,11 +121,22 @@ pub struct Metadata {
|
||||
pub dpi: Option<(f32, f32)>,
|
||||
/// Free-form key/value text (PNG tEXt/iTXt, TIFF ASCII tags).
|
||||
pub text: Vec<(String, String)>,
|
||||
/// Photoshop image resources (`8BIM` resource blocks): TIFF tag 34377. Kept opaque here;
|
||||
/// `photocraft-io` reads and writes them through `photocraft-psd`.
|
||||
pub photoshop_resources: Option<Vec<u8>>,
|
||||
/// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept
|
||||
/// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`.
|
||||
pub photoshop_layers: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.exif.is_none() && self.xmp.is_none() && self.dpi.is_none() && self.text.is_empty()
|
||||
self.exif.is_none()
|
||||
&& self.xmp.is_none()
|
||||
&& self.dpi.is_none()
|
||||
&& self.text.is_empty()
|
||||
&& self.photoshop_resources.is_none()
|
||||
&& self.photoshop_layers.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ 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};
|
||||
|
||||
@@ -86,7 +86,8 @@ fn icc_warning_suppressed_when_not_embedding() {
|
||||
#[test]
|
||||
fn exif_xmp_dpi_text_dropped_for_qoi() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())] };
|
||||
img.meta =
|
||||
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
|
||||
let w = warns(&img, Format::Qoi);
|
||||
for x in [W::ExifDropped, W::XmpDropped, W::DpiDropped, W::TextDropped] {
|
||||
assert!(w.contains(&x), "{x:?}");
|
||||
@@ -95,7 +96,8 @@ fn exif_xmp_dpi_text_dropped_for_qoi() {
|
||||
#[test]
|
||||
fn webp_drops_dpi_and_text_only() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta = Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())] };
|
||||
img.meta =
|
||||
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
|
||||
let w = warns(&img, Format::WebP);
|
||||
assert_eq!(w, vec![W::DpiDropped, W::TextDropped]);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ fn rich_image(layout: ChannelLayout) -> Image {
|
||||
xmp: Some(SAMPLE_XMP.to_owned()),
|
||||
dpi: Some((300.0, 150.0)),
|
||||
text: vec![("Description".into(), "synthetic test image".into()), ("Software".into(), "photocraft".into())],
|
||||
..Default::default()
|
||||
};
|
||||
img
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Photoshop's private TIFF tags (34377 image resources, 37724 ImageSourceData) travel through
|
||||
//! `Metadata` as opaque bytes: written when present, read from either byte order and from
|
||||
//! BigTIFF, and never a panic or an allocation on malformed input.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use photocraft_codecs::*;
|
||||
|
||||
fn with_tags(layout: ChannelLayout, sample: SampleType) -> Image {
|
||||
let mut img = synth(9, 7, layout, sample, 5, 0.3);
|
||||
img.meta.photoshop_resources = Some(b"8BIM\x03\xed\x00\x00\x00\x00\x00\x10\x00\x48\x00\x00\x00\x01\x00\x01\x00\x48\x00\x00\x00\x01\x00\x01".to_vec());
|
||||
let mut layers = b"Adobe Photoshop Document Data Block\0".to_vec();
|
||||
layers.extend_from_slice(b"MIB8ryaL");
|
||||
layers.extend_from_slice(&4u32.to_le_bytes());
|
||||
layers.extend_from_slice(&[0, 0, 0, 0]);
|
||||
img.meta.photoshop_layers = Some(layers);
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_round_trip_through_the_encoder() {
|
||||
for (layout, sample) in [
|
||||
(ChannelLayout::Rgb, SampleType::U8),
|
||||
(ChannelLayout::Rgba, SampleType::U16),
|
||||
(ChannelLayout::Gray, SampleType::F32),
|
||||
(ChannelLayout::CmykA, SampleType::U8),
|
||||
] {
|
||||
for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::Lzw, TiffCompression::PackBits] {
|
||||
let img = with_tags(layout, sample);
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap();
|
||||
// The encoder writes the running target's byte order.
|
||||
assert_eq!(bytes.starts_with(b"II"), tiff_writes_little_endian());
|
||||
let (res, layers) = tiff_photoshop_tags(&bytes);
|
||||
assert_eq!(res, img.meta.photoshop_resources.as_deref());
|
||||
assert_eq!(layers, img.meta.photoshop_layers.as_deref());
|
||||
let back = decode(&bytes).unwrap();
|
||||
assert_eq!(back.meta.photoshop_resources, img.meta.photoshop_resources, "{layout:?} {sample:?} {compression:?}");
|
||||
assert_eq!(back.meta.photoshop_layers, img.meta.photoshop_layers);
|
||||
assert_eq!(back.data(), img.data());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_tags_are_not_written() {
|
||||
let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0);
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(tiff_photoshop_tags(&bytes), (None, None));
|
||||
assert!(decode(&bytes).unwrap().meta.photoshop_layers.is_none());
|
||||
img.meta.photoshop_layers = Some(Vec::new());
|
||||
img.meta.photoshop_resources = Some(Vec::new());
|
||||
let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(tiff_photoshop_tags(&bytes), (None, None));
|
||||
}
|
||||
|
||||
/// A minimal uncompressed 2×1 gray TIFF with a 37724 tag, in either byte order, classic or
|
||||
/// BigTIFF: `payload` is stored inline when it fits the value field, at an offset otherwise.
|
||||
fn hand_made(little: bool, big: bool, payload: &[u8]) -> Vec<u8> {
|
||||
let w16 = |v: u16| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let w32 = |v: u32| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let w64 = |v: u64| if little { v.to_le_bytes().to_vec() } else { v.to_be_bytes().to_vec() };
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(if little { b"II" } else { b"MM" });
|
||||
let header_len = if big { 16 } else { 8 };
|
||||
let pixels_at = header_len;
|
||||
let ifd_at = pixels_at + 2;
|
||||
f.extend(w16(if big { 43 } else { 42 }));
|
||||
if big {
|
||||
f.extend(w16(8));
|
||||
f.extend(w16(0));
|
||||
f.extend(w64(ifd_at as u64));
|
||||
} else {
|
||||
f.extend(w32(ifd_at as u32));
|
||||
}
|
||||
f.extend_from_slice(&[0x40, 0xc0]);
|
||||
// Entries: width, length, bits, compression, photometric, strip offsets, samples, rows per
|
||||
// strip, strip byte counts, 37724.
|
||||
let tags: [(u16, u16, u64, u64); 10] = [
|
||||
(256, 3, 1, 2),
|
||||
(257, 3, 1, 1),
|
||||
(258, 3, 1, 8),
|
||||
(259, 3, 1, 1),
|
||||
(262, 3, 1, 1),
|
||||
(273, 4, 1, pixels_at as u64),
|
||||
(277, 3, 1, 1),
|
||||
(278, 3, 1, 1),
|
||||
(279, 4, 1, 2),
|
||||
(37724, 7, payload.len() as u64, 0),
|
||||
];
|
||||
let entry_len = if big { 20 } else { 12 };
|
||||
let inline = if big { 8 } else { 4 };
|
||||
let count_len = if big { 8 } else { 2 };
|
||||
let next_len = if big { 8 } else { 4 };
|
||||
let data_at = ifd_at + count_len + tags.len() * entry_len + next_len;
|
||||
let mut entries = Vec::new();
|
||||
for (tag, ty, count, value) in tags {
|
||||
entries.extend(w16(tag));
|
||||
entries.extend(w16(ty));
|
||||
entries.extend(if big { w64(count) } else { w32(count as u32) });
|
||||
let mut v = Vec::new();
|
||||
if tag == 37724 {
|
||||
if payload.len() <= inline {
|
||||
v.extend_from_slice(payload);
|
||||
} else {
|
||||
v.extend(if big { w64(data_at as u64) } else { w32(data_at as u32) });
|
||||
}
|
||||
} else if ty == 3 {
|
||||
v.extend(w16(value as u16));
|
||||
} else {
|
||||
v.extend(if big { w64(value) } else { w32(value as u32) });
|
||||
}
|
||||
v.resize(inline, 0);
|
||||
entries.extend(v);
|
||||
}
|
||||
f.extend(if big { w64(tags.len() as u64) } else { w16(tags.len() as u16) });
|
||||
f.extend(entries);
|
||||
f.extend(if big { w64(0) } else { w32(0) });
|
||||
assert_eq!(f.len(), data_at);
|
||||
if payload.len() > inline {
|
||||
f.extend_from_slice(payload);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_made_files_in_every_layout() {
|
||||
let long = b"Adobe Photoshop Document Data Block\0MIB8".to_vec();
|
||||
for little in [false, true] {
|
||||
for big in [false, true] {
|
||||
for payload in [&b"ab"[..], &b"abcd"[..], &b"abcdefgh"[..], &long[..]] {
|
||||
let f = hand_made(little, big, payload);
|
||||
let (res, layers) = tiff_photoshop_tags(&f);
|
||||
assert_eq!(res, None);
|
||||
assert_eq!(layers, Some(payload), "little {little} big {big} len {}", payload.len());
|
||||
let img = decode(&f).unwrap();
|
||||
assert_eq!(img.dimensions(), (2, 1));
|
||||
assert_eq!(img.meta.photoshop_layers.as_deref(), Some(payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_tags_read_as_absent_without_panicking() {
|
||||
let long = vec![7u8; 40];
|
||||
for little in [false, true] {
|
||||
for big in [false, true] {
|
||||
let f = hand_made(little, big, &long);
|
||||
// Truncated anywhere: never a panic; the payload is absent once it is cut.
|
||||
for cut in 0..f.len() {
|
||||
let (_, layers) = tiff_photoshop_tags(&f[..cut]);
|
||||
if cut < f.len() {
|
||||
assert!(layers.is_none_or(|l| l.len() == 40));
|
||||
}
|
||||
let _ = decode(&f[..cut]);
|
||||
}
|
||||
// Bit flips in the directory.
|
||||
for i in 0..f.len() - 40 {
|
||||
let mut g = f.clone();
|
||||
g[i] ^= 0xff;
|
||||
let _ = tiff_photoshop_tags(&g);
|
||||
let _ = decode(&g);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(tiff_photoshop_tags(b""), (None, None));
|
||||
assert_eq!(tiff_photoshop_tags(b"II*\0"), (None, None));
|
||||
assert_eq!(tiff_photoshop_tags(b"MM\0*\xff\xff\xff\xff"), (None, None));
|
||||
// A count that overflows the file.
|
||||
let mut f = hand_made(true, false, &long);
|
||||
let at = f.windows(2).position(|w| w == 37724u16.to_le_bytes()).unwrap();
|
||||
f[at + 4..at + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(tiff_photoshop_tags(&f).1, None);
|
||||
}
|
||||
@@ -821,7 +821,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
|
||||
"fileHandling.lowercaseExtension",
|
||||
"fileHandling.saveInBackground",
|
||||
"fileHandling.ignoreExifProfileTag",
|
||||
"fileHandling.askBeforeSavingLayeredTiff",
|
||||
"fileHandling.maximizePsdCompatibility",
|
||||
"performance.cacheLevels",
|
||||
"performance.effectCacheMb",
|
||||
|
||||
+21
-6
@@ -13,8 +13,23 @@ use crate::{ExportOptions, ExportResult, ImportResult, IoError};
|
||||
/// Bytes per band when converting or exporting a band of rows at a time.
|
||||
const BAND_BYTES: usize = 32 << 20;
|
||||
|
||||
/// Decodes a flat image into a single-layer document.
|
||||
/// Decodes a flat image into a single-layer document; a TIFF with Photoshop layer data opens
|
||||
/// layered (see [`crate::tiff_layers`]).
|
||||
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
|
||||
if codecs::detect(bytes) == Some(Format::Tiff) {
|
||||
// The orientation is applied after the layer check: rotating the composite but not the
|
||||
// layers would misalign them, so a layered TIFF keeps its stored orientation.
|
||||
let orientation = codecs::exif_orientation(bytes);
|
||||
let img = codecs::decode_with(bytes, &codecs::DecodeOptions { keep_orientation: true, ..Default::default() })?;
|
||||
if let Some(layers) = img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()) {
|
||||
let mut r = crate::tiff_layers::import_layered(name, &img, layers)?;
|
||||
if orientation != 1 {
|
||||
r.warnings.push("the TIFF's orientation tag was ignored so the layers stay aligned with the image".to_string());
|
||||
}
|
||||
return Ok(r);
|
||||
}
|
||||
return image_to_document(name, &img.oriented(orientation)?);
|
||||
}
|
||||
let img = codecs::decode(bytes)?;
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
// OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless
|
||||
@@ -83,7 +98,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
|
||||
/// `Some(surface)` when the document is exactly one visible, unmasked,
|
||||
/// normal, fully opaque raster layer: its pixels can be written natively
|
||||
/// (keeping CMYK / depth exactly) instead of going through the compositor.
|
||||
fn single_layer(doc: &Document) -> Option<&Surface> {
|
||||
pub(crate) fn single_layer(doc: &Document) -> Option<&Surface> {
|
||||
let [l] = &doc.layers[..] else { return None };
|
||||
let ok = l.visible
|
||||
&& l.opacity >= 1.0
|
||||
@@ -101,7 +116,7 @@ fn single_layer(doc: &Document) -> Option<&Surface> {
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout {
|
||||
pub(crate) fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout {
|
||||
match (mode, alpha) {
|
||||
(ColorMode::Grayscale, false) => ChannelLayout::Gray,
|
||||
(ColorMode::Grayscale, true) => ChannelLayout::GrayA,
|
||||
@@ -112,7 +127,7 @@ fn layout_for(mode: ColorMode, alpha: bool) -> ChannelLayout {
|
||||
}
|
||||
}
|
||||
|
||||
fn csample(s: SampleType) -> CSample {
|
||||
pub(crate) fn csample(s: SampleType) -> CSample {
|
||||
match s {
|
||||
SampleType::U8 => CSample::U8,
|
||||
SampleType::U16 => CSample::U16,
|
||||
@@ -209,14 +224,14 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec<String>) -> Result<I
|
||||
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
|
||||
xmp: doc.metadata.xmp.clone(),
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
text: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(img.with_icc(icc).with_meta(meta))
|
||||
}
|
||||
|
||||
/// An empty buffer with room for `pixels × bytes_per_pixel` bytes, or an error (not an abort)
|
||||
/// when that much memory can't be had.
|
||||
fn try_buffer(pixels: usize, bytes_per_pixel: usize) -> Result<Vec<u8>, IoError> {
|
||||
pub(crate) fn try_buffer(pixels: usize, bytes_per_pixel: usize) -> Result<Vec<u8>, IoError> {
|
||||
let len = pixels.checked_mul(bytes_per_pixel).ok_or_else(|| IoError::Unsupported("image too large".into()))?;
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| IoError::Unsupported(format!("not enough memory for a {} MB image", len >> 20)))?;
|
||||
|
||||
+17
-2
@@ -13,6 +13,8 @@
|
||||
//! * Camera raws (DNG, CR2, uncompressed / lossless TIFF-EP raws) via
|
||||
//! `photocraft-raw`, developed into a 16-bit ProPhoto RGB "Background"
|
||||
//! layer; unsupported raw variants fall back to the embedded JPEG preview.
|
||||
//! * Layered TIFFs (Photoshop layer data in tags 37724 and 34377) open with their
|
||||
//! layers through the PSD path and are written back the same way; see `tiff_layers`.
|
||||
//! * Every other format goes through `photocraft-codecs` as a single
|
||||
//! "Background" layer (depth and Gray/RGB/CMYK model preserved).
|
||||
//!
|
||||
@@ -40,6 +42,7 @@ pub mod raw;
|
||||
pub mod slices_map;
|
||||
pub mod smart_map;
|
||||
pub mod text_styles_map;
|
||||
pub mod tiff_layers;
|
||||
pub mod vector_map;
|
||||
|
||||
use photocraft_codecs::{CodecError, EncodeOptions};
|
||||
@@ -96,12 +99,21 @@ pub struct ExportResult {
|
||||
}
|
||||
|
||||
/// Export options.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExportOptions {
|
||||
/// Codec options for flat formats.
|
||||
pub encode: EncodeOptions,
|
||||
/// Write PSB even for `.psd` names when the document is small.
|
||||
pub force_psb: bool,
|
||||
/// TIFF: keep the layers (Photoshop layer data in tag 37724). `false` is Photoshop's
|
||||
/// "Discard Layers and Save a Copy": a flat TIFF.
|
||||
pub tiff_layers: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
fn default() -> Self {
|
||||
ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` if `bytes` start with the PSD/PSB signature.
|
||||
@@ -164,7 +176,7 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result
|
||||
return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc, &previews)?, warnings: Vec::new() });
|
||||
}
|
||||
if ext == "psd" || ext == "psb" {
|
||||
let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb" };
|
||||
let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", ..Default::default() };
|
||||
let (file, warnings) = document_to_psd_with(doc, &o);
|
||||
// Never write a header the reader would refuse (e.g. a zero-sized canvas).
|
||||
file.header.validate()?;
|
||||
@@ -172,6 +184,9 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result
|
||||
return Ok(ExportResult { bytes, warnings });
|
||||
}
|
||||
let format = photocraft_codecs::from_extension(&ext).ok_or_else(|| IoError::UnknownFormat(name_or_ext.to_string()))?;
|
||||
if format == photocraft_codecs::Format::Tiff && opts.tiff_layers && tiff_layers::would_write_layers(doc) {
|
||||
return tiff_layers::export_layered(doc, opts);
|
||||
}
|
||||
flat::export_flat(doc, format, opts)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,22 @@ const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024;
|
||||
const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024;
|
||||
|
||||
/// Options for [`document_to_psd_with`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PsdExportOptions {
|
||||
/// Write PSB even when the document fits PSD limits.
|
||||
pub force_psb: bool,
|
||||
/// Matte the merged image against white where it is translucent, as Photoshop does
|
||||
/// (`false` keeps straight colour under the alpha: for containers that store the composite
|
||||
/// with its own alpha, such as a layered TIFF).
|
||||
pub merged_matte: bool,
|
||||
/// Compression of the merged image (32-bit float documents are always written raw).
|
||||
pub merged_compression: Compression,
|
||||
}
|
||||
|
||||
impl Default for PsdExportOptions {
|
||||
fn default() -> Self {
|
||||
PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle }
|
||||
}
|
||||
}
|
||||
|
||||
struct Ex {
|
||||
@@ -701,7 +713,7 @@ impl Ex {
|
||||
return Err(format!("smart objects nest more than {MAX_NESTING} deep"));
|
||||
}
|
||||
let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?;
|
||||
let (file, warnings) = document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true }, self.smart.depth + 1);
|
||||
let (file, warnings) = document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1);
|
||||
let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?;
|
||||
let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a);
|
||||
self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}")));
|
||||
@@ -1137,8 +1149,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) -
|
||||
// Merged composite, rendered and encoded in bands (no full-size float composite). Matting
|
||||
// against white only changes pixels with alpha < 1; if some are slightly translucent but all
|
||||
// round to opaque (so no alpha channel is written), encode once more without the matte.
|
||||
let (mut planes, has_alpha, translucent) = merged_planes(doc, &fmt, cmyk_of(&fmt), true);
|
||||
if !has_alpha && translucent {
|
||||
let (mut planes, has_alpha, translucent) = merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte);
|
||||
if !has_alpha && translucent && opts.merged_matte {
|
||||
planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false).0;
|
||||
}
|
||||
let n = doc.size.area() as usize;
|
||||
@@ -1171,7 +1183,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) -
|
||||
}
|
||||
let channels = (cc + usize::from(has_alpha) + extra.len()) as u16;
|
||||
let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode));
|
||||
let mcomp = if sample == SampleType::F32 { Compression::Raw } else { Compression::Rle };
|
||||
let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression };
|
||||
let image_data = ImageData::encode(mcomp, &planes, &header)
|
||||
.or_else(|_| ImageData::encode(Compression::Raw, &planes, &header))
|
||||
.unwrap_or(ImageData { compression: Compression::Raw, data: planes });
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Layered TIFF: Photoshop layer data carried in the TIFF tags 37724 (`ImageSourceData`) and
|
||||
//! 34377 (image resources), see [`photocraft_psd::tiff`].
|
||||
//!
|
||||
//! Import rebuilds a PSD model from the TIFF's pixels plus those two tags and hands it to the
|
||||
//! PSD importer, so everything the PSD path understands (groups, masks, effects, type, shapes,
|
||||
//! smart objects, adjustment and fill layers, alpha channels, paths, guides) opens from a TIFF
|
||||
//! as well. Export runs the PSD exporter and stores its layer section and resources in the
|
||||
//! tags, in the byte order the TIFF encoder writes, next to the flattened composite the TIFF
|
||||
//! itself carries.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample};
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_psd::resources::ids;
|
||||
use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes, resources_to_bytes};
|
||||
use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, ImageResource, ResolutionInfo};
|
||||
|
||||
use crate::flat::{csample, image_to_document, layout_for, single_layer, try_buffer};
|
||||
use crate::psd_export::{PsdExportOptions, document_to_psd_with};
|
||||
use crate::psd_import::psd_to_document;
|
||||
use crate::{ExportOptions, ExportResult, ImportResult, IoError};
|
||||
|
||||
/// `true` when saving `doc` as TIFF writes layer data: anything beyond a single Background
|
||||
/// layer (one plain, transparency-locked raster layer), which a flat TIFF already represents
|
||||
/// exactly. A lone unlocked layer keeps its layer data, as in Photoshop, so it reopens as that
|
||||
/// layer rather than as a Background.
|
||||
pub fn would_write_layers(doc: &Document) -> bool {
|
||||
!(single_layer(doc).is_some() && doc.layers.first().is_some_and(|l| l.locks.transparency))
|
||||
}
|
||||
|
||||
/// Documents a layered TIFF can hold: the TIFF composite must be in the document's model so
|
||||
/// that the layer channels inside the tag match it (Lab TIFFs are not decoded or encoded by
|
||||
/// `photocraft-codecs`), and there must be layers to keep (a Multichannel document has none:
|
||||
/// its channels are the image).
|
||||
fn layered_mode(doc: &Document) -> bool {
|
||||
matches!(doc.pixel_format().mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk) && doc.mode != ColorMode::Multichannel && !doc.layers.is_empty()
|
||||
}
|
||||
|
||||
/// Replaces (or inserts) resource `id`; removes it when `data` is `None`.
|
||||
fn set_resource(resources: &mut Vec<ImageResource>, id: u16, data: Option<Vec<u8>>) {
|
||||
resources.retain(|r| r.id != id);
|
||||
if let Some(d) = data {
|
||||
resources.push(ImageResource::new(id, d));
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a TIFF whose `img` carries Photoshop layer data in `layers` (its 37724 tag).
|
||||
pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<ImportResult, IoError> {
|
||||
let mut warnings = Vec::new();
|
||||
let (isd, w) = match ImageSourceData::from_bytes(layers) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let mut r = image_to_document(name, img)?;
|
||||
r.warnings.push(format!("the Photoshop layer data could not be read ({e}); opened flattened"));
|
||||
return Ok(r);
|
||||
}
|
||||
};
|
||||
warnings.extend(w.into_iter().map(|w| format!("layer data: {w}")));
|
||||
let (mut resources, w) = match img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()).map(resources_from_bytes) {
|
||||
Some(Ok(v)) => v,
|
||||
Some(Err(e)) => {
|
||||
warnings.push(format!("the Photoshop image resources could not be read: {e}"));
|
||||
(Vec::new(), Vec::new())
|
||||
}
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
warnings.extend(w.into_iter().map(|w| format!("image resources: {w}")));
|
||||
// The profile, resolution, XMP and EXIF are the TIFF's own tags; they win over any copy
|
||||
// in the resources.
|
||||
set_resource(&mut resources, ids::ICC_PROFILE, img.icc.clone());
|
||||
if let Some((x, _)) = img.meta.dpi.filter(|d| d.0 > 0.0) {
|
||||
set_resource(&mut resources, ids::RESOLUTION_INFO, Some(ResolutionInfo::from_dpi(f64::from(x)).to_bytes()));
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
set_resource(&mut resources, ids::XMP, Some(xmp.as_bytes().to_vec()));
|
||||
}
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
set_resource(&mut resources, ids::EXIF, Some(exif.clone()));
|
||||
}
|
||||
|
||||
// The TIFF's pixels become the PSD's merged image: planar, big-endian, CMYK inverted.
|
||||
let img = if img.sample_type() == CSample::F16 {
|
||||
warnings.push("16-bit float samples are stored as 32-bit float".to_string());
|
||||
std::borrow::Cow::Owned(img.convert(img.layout(), CSample::F32))
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(img)
|
||||
};
|
||||
let (mode, color_channels) = match img.layout() {
|
||||
ChannelLayout::Gray | ChannelLayout::GrayA => (PsdMode::Grayscale, 1),
|
||||
ChannelLayout::Rgb | ChannelLayout::Rgba => (PsdMode::Rgb, 3),
|
||||
ChannelLayout::Cmyk | ChannelLayout::CmykA => (PsdMode::Cmyk, 4),
|
||||
};
|
||||
let depth = match img.sample_type() {
|
||||
CSample::U8 => 8,
|
||||
CSample::U16 => 16,
|
||||
CSample::F16 | CSample::F32 => 32,
|
||||
};
|
||||
let (w, h) = img.dimensions();
|
||||
let channels = u16::try_from(img.layout().channels()).map_err(|_| IoError::Unsupported("too many channels".into()))?;
|
||||
let header = Header::new(isd.version, w, h, channels, depth, mode);
|
||||
let data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?;
|
||||
let image_data = ImageData { compression: Compression::Raw, data };
|
||||
let file = isd.into_psd(header, resources, image_data);
|
||||
let (mut doc, w) = psd_to_document(&file);
|
||||
warnings.extend(w);
|
||||
doc.name = name.to_string();
|
||||
if doc.icc_profile.is_none() {
|
||||
doc.icc_profile = img.icc.clone().map(Arc::new);
|
||||
}
|
||||
if !img.meta.text.is_empty() {
|
||||
warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len()));
|
||||
}
|
||||
Ok(ImportResult { document: doc, warnings })
|
||||
}
|
||||
|
||||
/// Interleaved native-endian samples → planar big-endian planes (PSD merged-image order),
|
||||
/// inverting the colour channels of CMYK (PSD stores ink as 255 − value).
|
||||
fn planar_big_endian(img: &Image, color_channels: usize, invert_color: bool) -> Result<Vec<u8>, IoError> {
|
||||
let (w, h) = img.dimensions();
|
||||
let n = w as usize * h as usize;
|
||||
let ch = img.layout().channels();
|
||||
let bps = img.sample_type().bytes();
|
||||
let mut out = try_buffer(n * ch, bps)?;
|
||||
out.resize(n * ch * bps, 0);
|
||||
let data = img.data();
|
||||
for (c, plane) in out.chunks_exact_mut(n * bps).enumerate() {
|
||||
let invert = invert_color && c < color_channels;
|
||||
let pixels = data.chunks_exact(ch * bps).map(|px| &px[c * bps..(c + 1) * bps]);
|
||||
for (dst, src) in plane.chunks_exact_mut(bps).zip(pixels) {
|
||||
match (bps, src) {
|
||||
(1, [v]) => dst[0] = if invert { 255 - *v } else { *v },
|
||||
(2, [a, b]) => {
|
||||
let v = u16::from_ne_bytes([*a, *b]);
|
||||
dst.copy_from_slice(&(if invert { 65535 - v } else { v }).to_be_bytes());
|
||||
}
|
||||
(4, [a, b, c, d]) => {
|
||||
let v = f32::from_ne_bytes([*a, *b, *c, *d]);
|
||||
dst.copy_from_slice(&(if invert { 1.0 - v } else { v }).to_be_bytes());
|
||||
}
|
||||
_ => return Err(IoError::Unsupported(format!("{bps}-byte samples"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Planar big-endian planes (the PSD merged image) → an interleaved native-endian codec image
|
||||
/// of `channels` channels, un-inverting CMYK colour channels.
|
||||
fn interleave(planes: &[u8], w: u32, h: u32, channels: usize, color_channels: usize, sample: CSample, invert_color: bool) -> Result<Vec<u8>, IoError> {
|
||||
let n = w as usize * h as usize;
|
||||
let bps = sample.bytes();
|
||||
let plane = n * bps;
|
||||
if planes.len() < plane * channels {
|
||||
return Err(IoError::Unsupported("the merged image is shorter than its header says".into()));
|
||||
}
|
||||
let mut out = try_buffer(n * channels, bps)?;
|
||||
for i in 0..n {
|
||||
for c in 0..channels {
|
||||
let at = c * plane + i * bps;
|
||||
let invert = invert_color && c < color_channels;
|
||||
match planes.get(at..at + bps) {
|
||||
Some([v]) => out.push(if invert { 255 - *v } else { *v }),
|
||||
Some([a, b]) => {
|
||||
let v = u16::from_be_bytes([*a, *b]);
|
||||
out.extend_from_slice(&(if invert { 65535 - v } else { v }).to_ne_bytes());
|
||||
}
|
||||
Some([a, b, c, d]) => {
|
||||
let v = f32::from_be_bytes([*a, *b, *c, *d]);
|
||||
out.extend_from_slice(&(if invert { 1.0 - v } else { v }).to_ne_bytes());
|
||||
}
|
||||
_ => return Err(IoError::Unsupported(format!("{bps}-byte samples"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Saves `doc` as a layered TIFF. Documents whose colour mode a TIFF cannot hold in our
|
||||
/// codec (Lab) are saved flat, with a warning.
|
||||
pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions) -> Result<ExportResult, IoError> {
|
||||
if !layered_mode(doc) {
|
||||
let mut r = crate::flat::export_flat(doc, Format::Tiff, opts)?;
|
||||
if !doc.layers.is_empty() {
|
||||
r.warnings.push(format!("{:?} documents are saved as a flat TIFF: their layers are not kept", doc.pixel_format().mode));
|
||||
}
|
||||
return Ok(r);
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
// The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF
|
||||
// keeps straight alpha) and uncompressed (its planes are interleaved below).
|
||||
let psd_opts = PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw };
|
||||
let (file, w) = document_to_psd_with(doc, &psd_opts);
|
||||
warnings.extend(w);
|
||||
let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big };
|
||||
let (layers, w) = ImageSourceData::from_psd(&file).to_bytes(order)?;
|
||||
warnings.extend(w.into_iter().map(|w| format!("layer data: {w}")));
|
||||
// The profile and XMP are written as TIFF tags, not as resources. EXIF stays a resource
|
||||
// (1058): the TIFF codec has no EXIF directory, and the PSD importer reads it back from there.
|
||||
let resources: Vec<ImageResource> = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect();
|
||||
let resources = resources_to_bytes(&resources)?;
|
||||
|
||||
let fmt = doc.pixel_format();
|
||||
let cc = fmt.mode.color_channels();
|
||||
let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha);
|
||||
let channels = cc + usize::from(has_alpha);
|
||||
let extra = usize::from(file.header.channels).saturating_sub(channels);
|
||||
if extra > 0 {
|
||||
warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)"));
|
||||
}
|
||||
let sample = csample(fmt.sample);
|
||||
let (w, h) = (doc.size.width, doc.size.height);
|
||||
let cmyk = fmt.mode == ColorMode::Cmyk;
|
||||
let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?;
|
||||
let layout = layout_for(fmt.mode, has_alpha);
|
||||
let mut img = Image::from_raw(w, h, layout, sample, data)?;
|
||||
img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
|
||||
img.meta = codecs::Metadata {
|
||||
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
|
||||
xmp: doc.metadata.xmp.clone(),
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
photoshop_resources: Some(resources),
|
||||
photoshop_layers: Some(layers),
|
||||
..Default::default()
|
||||
};
|
||||
for w in codecs::fidelity_warnings_with(&img, Format::Tiff, &opts.encode) {
|
||||
if w.is_fatal() {
|
||||
return Err(IoError::Unsupported(w.to_string()));
|
||||
}
|
||||
warnings.push(w.to_string());
|
||||
}
|
||||
let bytes = codecs::encode(&img, Format::Tiff, &opts.encode)?;
|
||||
Ok(ExportResult { bytes, warnings })
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Layered TIFF against the real-file corpora (feature `corpus`; `cargo xtask test-corpus`):
|
||||
//! every PSD that imports is written as a layered TIFF and read back, and the result must equal
|
||||
//! the PSD round trip of the same document: the same layer structure (count, names, kinds) and
|
||||
//! the same render (within one 8-bit step). The layers go through the same PSD mapping in both
|
||||
//! directions, so any difference is a bug in the layer-data transcoder or the TIFF glue, not in
|
||||
//! the mapping. Documents a TIFF stores flat (a lone Background, Multichannel) only compare the
|
||||
//! render. A panic anywhere is a failure (Rule 9).
|
||||
//!
|
||||
//! The byte-order conversion is exercised for real: the TIFF encoder writes Intel order, so
|
||||
//! every block of every corpus file (effects, type, smart objects, adjustments, paths, …) is
|
||||
//! converted to little-endian on the way out and back on the way in. Blocks the transcoder
|
||||
//! doesn't know are dropped with a warning; the test lists the keys it saw dropped, and fails
|
||||
//! if any of them is one `photocraft-io` maps (a loss of fidelity we can fix).
|
||||
#![cfg(feature = "corpus")]
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_doc::{Document, Layer};
|
||||
use photocraft_io::*;
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect(&p, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn structure(layers: &[Layer], out: &mut Vec<String>, depth: usize) {
|
||||
for l in layers {
|
||||
out.push(format!(
|
||||
"{}{} [{}] {:?} {:.3} mask={} fx={}",
|
||||
" ".repeat(depth),
|
||||
l.name,
|
||||
l.content.kind_name(),
|
||||
l.blend,
|
||||
l.opacity,
|
||||
l.mask.is_some(),
|
||||
l.effects.items.len()
|
||||
));
|
||||
if let photocraft_doc::LayerContent::Group(g) = &l.content {
|
||||
structure(&g.children, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
|
||||
a.iter().zip(b).map(|(p, q)| (0..4).map(|c| (p[c] * p[3] - q[c] * q[3]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Keys the io crate reads from layer records or global blocks: dropping one of these loses
|
||||
/// something a user can see.
|
||||
const MAPPED: &[&[u8; 4]] = &[
|
||||
b"lfx2", b"lmfx", b"lrFX", b"TySh", b"SoLd", b"SoLE", b"PlLd", b"vmsk", b"vsms", b"vogk", b"vscg", b"vstk", b"SoCo", b"GdFl", b"PtFl", b"levl", b"curv",
|
||||
b"hue2", b"brit", b"thrs", b"post", b"expA", b"vibA", b"blnc", b"mixr", b"grdm", b"phfl", b"selc", b"blwh", b"clrL", b"luni", b"lsct", b"lyid", b"lclr",
|
||||
b"iOpa", b"clbl", b"infx", b"knko", b"lspf", b"fxrp", b"brst", b"shmd", b"Patt", b"FEid", b"FXid", b"FMsk", b"artb", b"artd", b"cinf",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn corpus_round_trips_through_layered_tiff() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus");
|
||||
assert!(root.join("psd").is_dir(), "corpus missing: run `cargo xtask corpus --all`");
|
||||
let mut files = Vec::new();
|
||||
for sub in ["psd", "psd-tools", "photoshop"] {
|
||||
collect(&root.join(sub), &mut files);
|
||||
}
|
||||
files.sort();
|
||||
assert!(!files.is_empty());
|
||||
let (mut tested, mut crashes, mut failures) = (0usize, Vec::new(), Vec::new());
|
||||
let mut dropped: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for path in &files {
|
||||
let name = path.strip_prefix(&root).unwrap_or(path).to_string_lossy().replace('\\', "/");
|
||||
let Ok(bytes) = std::fs::read(path) else { continue };
|
||||
let Ok(imported) = import(&name, &bytes) else { continue };
|
||||
let doc = imported.document;
|
||||
// Only modes a TIFF holds; everything else is saved flat by design. Multichannel documents
|
||||
// have no layers at all (their channels are the image): nothing here to test.
|
||||
if !matches!(doc.pixel_format().mode, photocraft_color::ColorMode::Grayscale | photocraft_color::ColorMode::Rgb | photocraft_color::ColorMode::Cmyk)
|
||||
|| doc.mode == photocraft_color::ColorMode::Multichannel
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if doc.size.width.max(doc.size.height) > 4096 {
|
||||
continue;
|
||||
}
|
||||
tested += 1;
|
||||
let res = catch_unwind(AssertUnwindSafe(|| -> Result<(), String> {
|
||||
let psd = export(&doc, "x.psd", &ExportOptions::default()).map_err(|e| format!("psd export: {e}"))?;
|
||||
let oracle = import("x.psd", &psd.bytes).map_err(|e| format!("psd re-import: {e}"))?.document;
|
||||
let out = export(&doc, "x.tif", &ExportOptions::default()).map_err(|e| format!("export: {e}"))?;
|
||||
for w in &out.warnings {
|
||||
if let Some(rest) = w.strip_prefix("layer data: ")
|
||||
&& let Some((key, _)) = rest.split_once(" block (")
|
||||
{
|
||||
*dropped.entry(key.to_string()).or_default() += 1;
|
||||
if MAPPED.iter().any(|k| &key.as_bytes() == k) {
|
||||
return Err(format!("mapped block dropped: {w}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let back = import("x.tif", &out.bytes).map_err(|e| format!("re-import: {e}"))?.document;
|
||||
let flat = !tiff_layers::would_write_layers(&doc);
|
||||
let (mut a, mut b) = (Vec::new(), Vec::new());
|
||||
structure(&oracle.layers, &mut a, 0);
|
||||
structure(&back.layers, &mut b, 0);
|
||||
if a != b && !flat {
|
||||
return Err(format!("layer structure changed:\n {}\n {}", a.join("\n "), b.join("\n ")));
|
||||
}
|
||||
let expected: Document = {
|
||||
let mut d = oracle.clone();
|
||||
d.channels.clear();
|
||||
d
|
||||
};
|
||||
let x = photocraft_compose::flatten(&expected).px;
|
||||
let y = photocraft_compose::flatten(&back).px;
|
||||
let diff = max_diff(&x, &y);
|
||||
if diff > 1.0 / 255.0 + 1e-5 {
|
||||
return Err(format!("render differs by {diff}"));
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
match res {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => failures.push(format!("{name}: {e}")),
|
||||
Err(_) => crashes.push(name.clone()),
|
||||
}
|
||||
}
|
||||
println!("layered TIFF round trips: {} tested, {} failed, {} crashed", tested, failures.len(), crashes.len());
|
||||
println!("blocks dropped on the little-endian path: {dropped:?}");
|
||||
for f in &failures {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
assert!(crashes.is_empty(), "crashes: {crashes:?}");
|
||||
assert!(failures.is_empty(), "{} failures (see above)", failures.len());
|
||||
assert!(tested > 100, "only {tested} files tested");
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Layered TIFF (Photoshop layer data in tags 37724 / 34377): Document → TIFF → Document round
|
||||
//! trips in every mode and depth, equivalence with the PSD path, the flat fall-backs, files
|
||||
//! written by an independent implementation (psdtags, both byte orders), and malformed input.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, LayerContent};
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::tiff::{ByteOrder, ImageSourceData};
|
||||
|
||||
fn tiff(doc: &Document, layers: bool) -> ExportResult {
|
||||
export(doc, "x.tif", &ExportOptions { tiff_layers: layers, ..Default::default() }).expect("export")
|
||||
}
|
||||
|
||||
/// The two Photoshop tags of a TIFF: (resources, layers).
|
||||
fn tags(bytes: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>) {
|
||||
let (r, l) = photocraft_codecs::tiff_photoshop_tags(bytes);
|
||||
(r.map(<[u8]>::to_vec), l.map(<[u8]>::to_vec))
|
||||
}
|
||||
|
||||
fn roundtrip(doc: &Document) -> Document {
|
||||
let r = tiff(doc, true);
|
||||
let (resources, layers) = tags(&r.bytes);
|
||||
let layers = layers.expect("layer data");
|
||||
assert!(resources.is_some_and(|r| !r.is_empty()), "image resources");
|
||||
// The layer data matches the container's byte order and is byte-stable through the psd crate.
|
||||
let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big };
|
||||
let (isd, w) = ImageSourceData::from_bytes(&layers).expect("parse layer data");
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(isd.byte_order, order);
|
||||
assert_eq!(isd.to_bytes(order).unwrap().0, layers);
|
||||
// The TIFF is a plain image to any other reader: the composite at the document's size.
|
||||
let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode");
|
||||
assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height));
|
||||
// A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not
|
||||
// kept, and the export says so.
|
||||
let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some());
|
||||
assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings);
|
||||
import("x.tif", &r.bytes).expect("import").document
|
||||
}
|
||||
|
||||
/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask.
|
||||
fn expected(doc: &Document) -> Document {
|
||||
let mut d = doc.clone();
|
||||
d.channels.clear();
|
||||
d.quick_mask = None;
|
||||
d
|
||||
}
|
||||
|
||||
macro_rules! rt_case {
|
||||
($name:ident, $mode:expr, $depth:expr, $f:expr) => {
|
||||
#[test]
|
||||
fn $name() {
|
||||
let d = gen_doc($mode, $depth, $f);
|
||||
let back = roundtrip(&d);
|
||||
assert_docs_eq(&expected(&d), &back);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
rt_case!(rt_rgb8_all, ColorMode::Rgb, SampleType::U8, Features::ALL);
|
||||
rt_case!(rt_rgb16_all, ColorMode::Rgb, SampleType::U16, Features::ALL);
|
||||
rt_case!(rt_rgb32_all, ColorMode::Rgb, SampleType::F32, Features::ALL);
|
||||
rt_case!(rt_gray8_all, ColorMode::Grayscale, SampleType::U8, Features::ALL);
|
||||
rt_case!(rt_gray16_all, ColorMode::Grayscale, SampleType::U16, Features::ALL);
|
||||
rt_case!(rt_gray32_all, ColorMode::Grayscale, SampleType::F32, Features::ALL);
|
||||
rt_case!(rt_cmyk8_all, ColorMode::Cmyk, SampleType::U8, Features::ALL);
|
||||
rt_case!(rt_cmyk16_all, ColorMode::Cmyk, SampleType::U16, Features::ALL);
|
||||
rt_case!(rt_rgb8_pixels, ColorMode::Rgb, SampleType::U8, Features::PIXELS);
|
||||
rt_case!(rt_gray16_pixels, ColorMode::Grayscale, SampleType::U16, Features::PIXELS);
|
||||
rt_case!(rt_cmyk8_pixels, ColorMode::Cmyk, SampleType::U8, Features::PIXELS);
|
||||
|
||||
#[test]
|
||||
fn tiff_and_psd_open_as_the_same_document() {
|
||||
for (mode, depth) in [(ColorMode::Rgb, SampleType::U8), (ColorMode::Cmyk, SampleType::U16), (ColorMode::Grayscale, SampleType::F32)] {
|
||||
let d = gen_doc(mode, depth, Features::ALL);
|
||||
let psd = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let from_psd = import("x.psd", &psd.bytes).unwrap().document;
|
||||
let from_tif = roundtrip(&d);
|
||||
assert_docs_eq(&expected(&from_psd), &from_tif);
|
||||
// The composite the TIFF carries is what the document looks like.
|
||||
let tif = tiff(&d, true);
|
||||
let flat = import(
|
||||
"flat.tif",
|
||||
&photocraft_codecs::encode(&photocraft_codecs::decode(&tif.bytes).unwrap(), photocraft_codecs::Format::Tiff, &Default::default()).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.document;
|
||||
let a = photocraft_compose::flatten(&d).px;
|
||||
let b = photocraft_compose::flatten(&flat).px;
|
||||
assert!(max_diff(&a, &b) <= 2.0 / 255.0, "{mode:?} {depth:?}: composite differs by {}", max_diff(&a, &b));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_compression_keeps_the_layers() {
|
||||
use photocraft_codecs::TiffCompression;
|
||||
let d = gen_doc(ColorMode::Rgb, SampleType::U16, Features::PIXELS);
|
||||
for c in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
let mut opts = ExportOptions::default();
|
||||
opts.encode.tiff_compression = c;
|
||||
let r = export(&d, "x.tiff", &opts).unwrap();
|
||||
let back = import("x.tiff", &r.bytes).unwrap().document;
|
||||
assert_docs_eq(&d, &back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_layers_writes_a_flat_file() {
|
||||
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
|
||||
let r = tiff(&d, false);
|
||||
assert_eq!(tags(&r.bytes), (None, None));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("flattened")), "{:?}", r.warnings);
|
||||
let back = import("x.tif", &r.bytes).unwrap().document;
|
||||
assert_eq!(back.layers.len(), 1);
|
||||
assert!(!tiff_layers::would_write_layers(&back));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_background_needs_no_layer_data() {
|
||||
let d = import("x.png", &export(&gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS), "x.png", &Default::default()).unwrap().bytes).unwrap().document;
|
||||
assert_eq!(d.layers.len(), 1);
|
||||
assert!(!tiff_layers::would_write_layers(&d));
|
||||
let r = tiff(&d, true);
|
||||
assert_eq!(tags(&r.bytes), (None, None));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let back = import("x.tif", &r.bytes).unwrap().document;
|
||||
assert_docs_eq(&d, &back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_documents_are_saved_flat_with_a_warning() {
|
||||
let d = gen_doc(ColorMode::Lab, SampleType::U8, Features::PIXELS);
|
||||
let r = tiff(&d, true);
|
||||
assert_eq!(tags(&r.bytes), (None, None));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("Lab") && w.contains("layers are not kept")), "{:?}", r.warnings);
|
||||
assert!(import("x.tif", &r.bytes).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_is_straight_in_the_composite() {
|
||||
// A translucent layer over nothing: the TIFF's composite keeps its colour under the alpha
|
||||
// instead of being matted against white like a PSD's merged image.
|
||||
let mut d = Document::new("t", photocraft_geom::Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
|
||||
let fmt = d.pixel_format();
|
||||
let mut l = raster("Red", fmt, photocraft_geom::Rect::new(0, 0, 4, 4), 0, false);
|
||||
if let LayerContent::Raster(s) = &mut l.content {
|
||||
let px: Vec<f32> = (0..16).flat_map(|_| [1.0, 0.0, 0.0, 0.5]).collect();
|
||||
s.write_region(photocraft_geom::Rect::new(0, 0, 4, 4), &px);
|
||||
}
|
||||
d.layers.push(l);
|
||||
let r = tiff(&d, true);
|
||||
let flat = photocraft_codecs::decode(&r.bytes).unwrap();
|
||||
assert!(flat.layout().has_alpha());
|
||||
let px = flat.data();
|
||||
assert_eq!(&px[..4], &[255, 0, 0, 128]);
|
||||
let back = import("x.tif", &r.bytes).unwrap().document;
|
||||
assert_docs_eq(&d, &back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_documents_use_the_psb_signature() {
|
||||
// Forcing PSB stands in for a > 30000 px document.
|
||||
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
|
||||
let r = export(&d, "x.tif", &ExportOptions { force_psb: true, ..Default::default() }).unwrap();
|
||||
let (_, layers) = tags(&r.bytes);
|
||||
assert!(layers.unwrap().starts_with(photocraft_psd::tiff::SIGNATURE_PSB));
|
||||
let back = import("x.tif", &r.bytes).unwrap().document;
|
||||
assert_docs_eq(&d, &back);
|
||||
}
|
||||
|
||||
/// Files written by psdtags (`scripts/layered_tiff_fixtures.py`): two RGB layers, "Background"
|
||||
/// (RLE) and an offset, translucent "Upper é" (ZIP, Multiply, opacity 200), in both byte orders
|
||||
/// at 8 and 16 bits. Binary files stay out of this repository: run the script to write them
|
||||
/// to `corpus/layered-tiff/` (gitignored); without them these checks are skipped.
|
||||
fn fixture(name: &str) -> Option<Vec<u8>> {
|
||||
let bytes = std::fs::read(format!("{}/../../corpus/layered-tiff/{name}", env!("CARGO_MANIFEST_DIR"))).ok();
|
||||
if bytes.is_none() {
|
||||
eprintln!("skipped {name}: run `python scripts/layered_tiff_fixtures.py` to write corpus/layered-tiff/");
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psdtags_fixtures_open_with_their_layers() {
|
||||
for (name, depth) in [
|
||||
("layered-le-8bit.tif", SampleType::U8),
|
||||
("layered-be-8bit.tif", SampleType::U8),
|
||||
("layered-le-16bit.tif", SampleType::U16),
|
||||
("layered-be-16bit.tif", SampleType::U16),
|
||||
] {
|
||||
let Some(bytes) = fixture(name) else { continue };
|
||||
let r = import(name, &bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let d = &r.document;
|
||||
assert_eq!(d.size, photocraft_geom::Size::new(6, 4), "{name}");
|
||||
assert_eq!(d.mode, ColorMode::Rgb);
|
||||
assert_eq!(d.depth, depth, "{name}");
|
||||
let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["Background", "Upper \u{e9}"], "{name}");
|
||||
let upper = &d.layers[1];
|
||||
assert_eq!(upper.blend, photocraft_color::BlendMode::Multiply);
|
||||
assert!((upper.opacity - 200.0 / 255.0).abs() < 1e-6);
|
||||
let LayerContent::Raster(s) = &upper.content else { panic!("raster") };
|
||||
let bounds = s.content_bounds();
|
||||
assert_eq!((bounds.x0, bounds.y0, bounds.width(), bounds.height()), (1, 1, 4, 3), "{name}");
|
||||
// Pixel (x=2, y=0) of the upper layer: the generator's ramp, alpha 160/255.
|
||||
let px = s.pixel(3, 1);
|
||||
let expect = |v: u32| (v % 256) as f32 / 255.0;
|
||||
assert!((px[0] - expect(2 * 40 + 7)).abs() < 1e-3, "{name}: {px:?}");
|
||||
assert!((px[1] - expect(7 * 3)).abs() < 1e-3, "{name}: {px:?}");
|
||||
assert!((px[3] - 160.0 / 255.0).abs() < 1e-3, "{name}: {px:?}");
|
||||
let LayerContent::Raster(bg) = &d.layers[0].content else { panic!("raster") };
|
||||
let b = bg.pixel(5, 3);
|
||||
assert!((b[0] - expect(5 * 40 + 1)).abs() < 1e-3, "{name}: {b:?}");
|
||||
assert!((b[3] - 1.0).abs() < 1e-6);
|
||||
assert_eq!(d.resolution_dpi, 300.0);
|
||||
// tifffile writes a Software tag; nothing else is worth a warning.
|
||||
assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings);
|
||||
// psdtags wrote the composite's alpha sample without the transparency flag, so by the
|
||||
// PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps
|
||||
// everything else; the file is then in the encoder's byte order.
|
||||
assert_eq!(d.channels.len(), 1, "{name}");
|
||||
let out = tiff(d, true);
|
||||
let back = import(name, &out.bytes).unwrap().document;
|
||||
assert_docs_eq(&expected(d), &back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_layer_data_opens_flattened() {
|
||||
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
|
||||
let r = tiff(&d, true);
|
||||
let (_, layers) = tags(&r.bytes);
|
||||
let layers = layers.unwrap();
|
||||
let at = r.bytes.windows(layers.len()).position(|w| w == &layers[..]).unwrap();
|
||||
// Garbage after the signature: no layers, a warning, the composite as one layer.
|
||||
let mut bad = r.bytes.clone();
|
||||
for b in &mut bad[at + 36..at + 36 + 24] {
|
||||
*b ^= 0xa5;
|
||||
}
|
||||
let r2 = import("x.tif", &bad).unwrap();
|
||||
assert_eq!(r2.document.layers.len(), 1);
|
||||
assert!(r2.warnings.iter().any(|w| w.contains("could not be read")), "{:?}", r2.warnings);
|
||||
// Every truncation and a sweep of bit flips through the whole file: never a panic.
|
||||
for cut in (0..r.bytes.len()).step_by(11) {
|
||||
let _ = import("x.tif", &r.bytes[..cut]);
|
||||
}
|
||||
for i in (0..r.bytes.len()).step_by(29) {
|
||||
let mut b = r.bytes.clone();
|
||||
b[i] ^= 0x5a;
|
||||
let _ = import("x.tif", &b);
|
||||
}
|
||||
for name in ["layered-le-8bit.tif", "layered-be-16bit.tif"] {
|
||||
let Some(bytes) = fixture(name) else { continue };
|
||||
for cut in (0..bytes.len()).step_by(7) {
|
||||
let _ = import(name, &bytes[..cut]);
|
||||
}
|
||||
for i in (0..bytes.len()).step_by(5) {
|
||||
let mut b = bytes.clone();
|
||||
b[i] ^= 0xff;
|
||||
let _ = import(name, &b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,10 @@ path = "fuzz_targets/abr.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "image_source_data"
|
||||
path = "fuzz_targets/image_source_data.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Fuzz the layered-TIFF tag parsers (37724 image source data in either byte order, and 34377
|
||||
//! image resources), and the byte-order transcoder on anything that parses.
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = resources_from_bytes(data);
|
||||
let Ok((d, _)) = ImageSourceData::from_bytes(data) else { return };
|
||||
// Whatever parsed must write in both orders, and each output must parse again.
|
||||
for order in [ByteOrder::Big, ByteOrder::Little] {
|
||||
if let Ok((out, _)) = d.to_bytes(order) {
|
||||
let (again, _) = ImageSourceData::from_bytes(&out).expect("re-parse of written data");
|
||||
let _ = again.to_bytes(order);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -27,7 +27,7 @@ use crate::io::{Reader, WriteExt, read_pascal, write_pascal};
|
||||
use crate::layer::Rect;
|
||||
|
||||
/// Most slots an item may declare (Photoshop writes 24 channels + 2).
|
||||
const MAX_SLOTS: u32 = 64;
|
||||
pub(crate) const MAX_SLOTS: u32 = 64;
|
||||
|
||||
/// One compressed plane of a filter-effects item.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
+26
-10
@@ -2,16 +2,24 @@
|
||||
|
||||
use crate::error::{PsdError, Result};
|
||||
|
||||
/// A cursor over a byte slice. Every read is bounds checked.
|
||||
/// A cursor over a byte slice. Every read is bounds checked. Integers are big-endian (the PSD
|
||||
/// byte order) unless the reader was made with [`Reader::new_little`], which only the TIFF
|
||||
/// byte-order transcoder uses ([`crate::tiff`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
little: bool,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub(crate) fn new(data: &'a [u8]) -> Self {
|
||||
Reader { data, pos: 0 }
|
||||
Reader { data, pos: 0, little: false }
|
||||
}
|
||||
|
||||
/// A reader of little-endian integers (Photoshop data inside an Intel-order TIFF).
|
||||
pub(crate) fn new_little(data: &'a [u8]) -> Self {
|
||||
Reader { data, pos: 0, little: true }
|
||||
}
|
||||
|
||||
pub(crate) fn pos(&self) -> usize {
|
||||
@@ -47,7 +55,8 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
pub(crate) fn sub(&mut self, n: u64) -> Result<Reader<'a>> {
|
||||
Ok(Reader::new(self.bytes_u64(n)?))
|
||||
let little = self.little;
|
||||
Ok(Reader { data: self.bytes_u64(n)?, pos: 0, little })
|
||||
}
|
||||
|
||||
pub(crate) fn skip(&mut self, n: usize) -> Result<()> {
|
||||
@@ -65,25 +74,32 @@ impl<'a> Reader<'a> {
|
||||
Ok(self.array::<1>()?[0])
|
||||
}
|
||||
pub(crate) fn u16(&mut self) -> Result<u16> {
|
||||
Ok(u16::from_be_bytes(self.array()?))
|
||||
let b = self.array()?;
|
||||
Ok(if self.little { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
|
||||
}
|
||||
pub(crate) fn i16(&mut self) -> Result<i16> {
|
||||
Ok(i16::from_be_bytes(self.array()?))
|
||||
let b = self.array()?;
|
||||
Ok(if self.little { i16::from_le_bytes(b) } else { i16::from_be_bytes(b) })
|
||||
}
|
||||
pub(crate) fn u32(&mut self) -> Result<u32> {
|
||||
Ok(u32::from_be_bytes(self.array()?))
|
||||
let b = self.array()?;
|
||||
Ok(if self.little { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
|
||||
}
|
||||
pub(crate) fn i32(&mut self) -> Result<i32> {
|
||||
Ok(i32::from_be_bytes(self.array()?))
|
||||
let b = self.array()?;
|
||||
Ok(if self.little { i32::from_le_bytes(b) } else { i32::from_be_bytes(b) })
|
||||
}
|
||||
pub(crate) fn u64(&mut self) -> Result<u64> {
|
||||
Ok(u64::from_be_bytes(self.array()?))
|
||||
let b = self.array()?;
|
||||
Ok(if self.little { u64::from_le_bytes(b) } else { u64::from_be_bytes(b) })
|
||||
}
|
||||
pub(crate) fn i64(&mut self) -> Result<i64> {
|
||||
Ok(i64::from_be_bytes(self.array()?))
|
||||
let b = self.array()?;
|
||||
Ok(if self.little { i64::from_le_bytes(b) } else { i64::from_be_bytes(b) })
|
||||
}
|
||||
pub(crate) fn f64(&mut self) -> Result<f64> {
|
||||
Ok(f64::from_be_bytes(self.array()?))
|
||||
let b = self.array()?;
|
||||
Ok(if self.little { f64::from_le_bytes(b) } else { f64::from_be_bytes(b) })
|
||||
}
|
||||
|
||||
/// Reads a length field that is 4 bytes, or 8 bytes when `long`.
|
||||
|
||||
@@ -189,7 +189,7 @@ fn default_true() -> bool {
|
||||
}
|
||||
|
||||
/// Byte length of the mask parameter values announced by the parameter flags byte `pf`.
|
||||
fn mask_parameter_bytes(pf: u8) -> usize {
|
||||
pub(crate) fn mask_parameter_bytes(pf: u8) -> usize {
|
||||
usize::from(pf & 1 != 0) + 8 * usize::from(pf & 2 != 0) + usize::from(pf & 4 != 0) + 8 * usize::from(pf & 8 != 0)
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ impl LayerMask {
|
||||
/// Whether `d` (a whole mask record with mask parameters) stores the real-mask fields before
|
||||
/// the parameters, as Photoshop does. The spec's order wins when it reads as parameters
|
||||
/// followed by padding alone (no real mask), the one layout the two orders can share.
|
||||
fn real_before_parameters(d: &[u8]) -> bool {
|
||||
pub(crate) fn real_before_parameters(d: &[u8]) -> bool {
|
||||
let fits =
|
||||
|pf_at: usize| d.get(pf_at).is_some_and(|&pf| (pf_at + 1 + mask_parameter_bytes(pf)..=pf_at + 4 + mask_parameter_bytes(pf)).contains(&d.len()));
|
||||
!fits(18) && fits(36)
|
||||
|
||||
@@ -65,6 +65,7 @@ pub mod tagged;
|
||||
// Test-data generator (tests and fuzz seeds only): failing loudly is the point.
|
||||
#[allow(clippy::expect_used)]
|
||||
pub mod testgen;
|
||||
pub mod tiff;
|
||||
pub mod tree;
|
||||
|
||||
pub use blend::BlendMode;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -16,6 +16,7 @@ photocraft-doc = { workspace = true }
|
||||
photocraft-compose = { workspace = true }
|
||||
photocraft-gpu = { workspace = true }
|
||||
photocraft-engine = { workspace = true }
|
||||
photocraft-io = { workspace = true }
|
||||
photocraft-plugins = { workspace = true }
|
||||
photocraft-algo = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
@@ -38,7 +39,6 @@ sys-locale = "=0.3.2"
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] }
|
||||
# Only for examples/snapshot.rs (offscreen UI renders of real files).
|
||||
photocraft-io = { workspace = true }
|
||||
# Only for examples/interactive_bench.rs (JPEG encode, .pcraft saves).
|
||||
photocraft-codecs = { workspace = true }
|
||||
# Benchmark helpers for the `--json` benches `cargo xtask perf` runs (in-process RSS, percentiles).
|
||||
|
||||
@@ -96,7 +96,7 @@ fn s_fmt(f: &Map<String, Value>) -> String {
|
||||
}
|
||||
|
||||
fn settings(f: &Map<String, Value>) -> ExportSettings {
|
||||
ExportSettings { jpeg_quality: (s_fmt(f) == "jpg").then(|| n(f, "quality", 85.0).clamp(1.0, 100.0) as u8) }
|
||||
ExportSettings { jpeg_quality: (s_fmt(f) == "jpg").then(|| n(f, "quality", 85.0).clamp(1.0, 100.0) as u8), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Estimated size (bytes) from a ≤512 px proxy encode, scaled by pixel count.
|
||||
|
||||
@@ -2020,7 +2020,6 @@
|
||||
4 x 4 4 × 4
|
||||
5 x 5 5 × 5
|
||||
Grid: Mřížka:
|
||||
|
||||
# Rendering mode and recovery
|
||||
Rendering Mode Režim vykreslování
|
||||
Automatic (recommended) Automaticky (doporučeno)
|
||||
@@ -2035,10 +2034,8 @@
|
||||
Advanced Pokročilé
|
||||
GPU Backend Backend GPU
|
||||
Details Podrobnosti
|
||||
|
||||
Rendering mode Režim vykreslování
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Laso zmrazení: tažením zmrazíte oblast, tažením s klávesou Alt ji rozmrazíte (L)
|
||||
|
||||
Shadow Clipping Warning (U) Varování oříznutí stínů (U)
|
||||
Highlight Clipping Warning (O) Varování oříznutí světel (O)
|
||||
Show Lab Color Readouts Zobrazit hodnoty barev Lab
|
||||
@@ -2047,9 +2044,7 @@
|
||||
Show Skin Tone Indicator Zobrazit indikátor pleťových tónů
|
||||
Tone Histogram Histogram tónů
|
||||
Show Selected Region Zobrazit vybranou oblast
|
||||
|
||||
cameraRaw Light Světlo
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Filtr Camera Raw ({layer})
|
||||
cameraRaw Red Červené
|
||||
@@ -2072,3 +2067,9 @@ cameraRaw Color Barvy
|
||||
Enable {name} Povolit {name}
|
||||
Hold Alt while drawing for straight segments Podržením Alt při kreslení vytvoříte rovné úseky
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Táhněte nebo klikejte body mnohoúhelníku · podržením Alt během lasa kreslíte rovné úseky · {add} přidat · {sub} před kreslením odečte
|
||||
TIFF Options Možnosti TIFF
|
||||
Including layers will increase file size. Zahrnutí vrstev zvětší velikost souboru.
|
||||
Discard Layers and Save a Copy Zahodit vrstvy a uložit kopii
|
||||
Don't show again Příště nezobrazovat
|
||||
Save failed: {error} Uložení se nezdařilo: {error}
|
||||
Save cancelled Ukládání zrušeno
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2113,3 +2113,9 @@
|
||||
Enable {name} {name} aktivieren
|
||||
Hold Alt while drawing for straight segments Beim Zeichnen Alt gedrückt halten für gerade Segmente
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Ziehen oder Polygonpunkte klicken · Alt während des Lassos halten für gerade Segmente · {add} hinzufügen · {sub} vor dem Zeichnen zieht ab
|
||||
TIFF Options TIFF-Optionen
|
||||
Including layers will increase file size. Das Einbeziehen von Ebenen vergrößert die Datei.
|
||||
Discard Layers and Save a Copy Ebenen verwerfen und Kopie speichern
|
||||
Don't show again Nicht mehr anzeigen
|
||||
Save failed: {error} Speichern fehlgeschlagen: {error}
|
||||
Save cancelled Speichern abgebrochen
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2006,7 +2006,6 @@
|
||||
4 x 4 4 × 4
|
||||
5 x 5 5 × 5
|
||||
Grid: Rejilla:
|
||||
|
||||
# Tool, filter, warp-style, path, layer-comp and metadata names that fell outside the `tl!` test.
|
||||
# Spanish follows the tool wording used above (Herramienta X).
|
||||
Color Range Gama de colores
|
||||
@@ -2092,7 +2091,6 @@
|
||||
New Channel Nuevo canal
|
||||
Save Selection Guardar selección
|
||||
Load Selection Cargar selección
|
||||
|
||||
# Command-id override: under Edit > Purge a bare "Deshacer" reads as an action, not the history.
|
||||
@id edit.purge.undo Historial de deshacer
|
||||
# Rendering mode and recovery
|
||||
@@ -2109,11 +2107,8 @@
|
||||
Advanced Avanzado
|
||||
GPU Backend Motor GPU
|
||||
Details Detalles
|
||||
|
||||
Rendering mode Modo de renderizado
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Lazo de congelación: arrastre para congelar un área; Alt+arrastrar para descongelarla (L)
|
||||
|
||||
|
||||
Shadow Clipping Warning (U) Aviso de recorte de sombras (U)
|
||||
Highlight Clipping Warning (O) Aviso de recorte de luces (O)
|
||||
Show Lab Color Readouts Mostrar valores de color Lab
|
||||
@@ -2122,9 +2117,7 @@
|
||||
Show Skin Tone Indicator Mostrar indicador de tono de piel
|
||||
Tone Histogram Histograma tonal
|
||||
Show Selected Region Mostrar región seleccionada
|
||||
|
||||
cameraRaw Light Luz
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Filtro Camera Raw ({layer})
|
||||
cameraRaw Lights Tonos claros
|
||||
@@ -2149,3 +2142,9 @@ cameraRaw Amount Cantidad
|
||||
Enable {name} Activar {name}
|
||||
Hold Alt while drawing for straight segments Mantenga Alt al dibujar para segmentos rectos
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Arrastre o haga clic en los puntos del polígono · mantenga Alt durante el lazo para segmentos rectos · {add} añadir · {sub} antes de dibujar resta
|
||||
TIFF Options Opciones de TIFF
|
||||
Including layers will increase file size. Incluir las capas aumentará el tamaño del archivo.
|
||||
Discard Layers and Save a Copy Descartar capas y guardar una copia
|
||||
Don't show again No volver a mostrar
|
||||
Save failed: {error} Error al guardar: {error}
|
||||
Save cancelled Guardado cancelado
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2502,3 +2502,9 @@ cameraRaw Blending Fusion
|
||||
Enable {name} Activer {name}
|
||||
Hold Alt while drawing for straight segments Maintenez Alt en dessinant pour des segments droits
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Faites glisser ou cliquez des points de polygone · maintenez Alt pendant le lasso pour des segments droits · {add} ajouter · {sub} avant de dessiner soustrait
|
||||
TIFF Options Options TIFF
|
||||
Including layers will increase file size. L'inclusion des calques augmentera la taille du fichier.
|
||||
Discard Layers and Save a Copy Supprimer les calques et enregistrer une copie
|
||||
Don't show again Ne plus afficher
|
||||
Save failed: {error} Échec de l'enregistrement : {error}
|
||||
Save cancelled Enregistrement annulé
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2489,3 +2489,9 @@ cameraRaw Blending Pembauran
|
||||
Enable {name} Aktifkan {name}
|
||||
Hold Alt while drawing for straight segments Tahan Alt saat menggambar untuk segmen lurus
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Seret atau klik titik poligon · tahan Alt saat lasso untuk segmen lurus · {add} tambah · {sub} sebelum menggambar mengurangi
|
||||
TIFF Options Opsi TIFF
|
||||
Including layers will increase file size. Menyertakan layer akan memperbesar ukuran file.
|
||||
Discard Layers and Save a Copy Buang Layer dan Simpan Salinan
|
||||
Don't show again Jangan tampilkan lagi
|
||||
Save failed: {error} Gagal menyimpan: {error}
|
||||
Save cancelled Penyimpanan dibatalkan
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2010,7 +2010,6 @@
|
||||
4 x 4 4 × 4
|
||||
5 x 5 5 × 5
|
||||
Grid: グリッド:
|
||||
|
||||
# Rendering mode and recovery
|
||||
Rendering Mode レンダリングモード
|
||||
Automatic (recommended) 自動(推奨)
|
||||
@@ -2025,10 +2024,8 @@
|
||||
Advanced 詳細設定
|
||||
GPU Backend GPUバックエンド
|
||||
Details 詳細
|
||||
|
||||
Rendering mode レンダリングモード
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) フリーズなげなわ:ドラッグで範囲をフリーズ、Alt+ドラッグで解凍 (L)
|
||||
|
||||
Shadow Clipping Warning (U) シャドウのクリッピング警告 (U)
|
||||
Highlight Clipping Warning (O) ハイライトのクリッピング警告 (O)
|
||||
Show Lab Color Readouts Lab カラー値を表示
|
||||
@@ -2037,9 +2034,7 @@
|
||||
Show Skin Tone Indicator スキントーン指標を表示
|
||||
Tone Histogram 階調ヒストグラム
|
||||
Show Selected Region 選択範囲を表示
|
||||
|
||||
cameraRaw Light ライト
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Camera Raw フィルター ({layer})
|
||||
cameraRaw Lights 明部
|
||||
@@ -2055,3 +2050,9 @@ cameraRaw Color カラー
|
||||
Enable {name} {name}を有効にする
|
||||
Hold Alt while drawing for straight segments 描画中に Alt を押すと直線セグメントになります
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts ドラッグまたは多角形の点をクリック · なげなわ中に Alt で直線セグメント · {add} 追加 · 描画前の {sub} で削除
|
||||
TIFF Options TIFF オプション
|
||||
Including layers will increase file size. レイヤーを含めるとファイルサイズが大きくなります。
|
||||
Discard Layers and Save a Copy レイヤーを破棄してコピーを保存
|
||||
Don't show again 次回から表示しない
|
||||
Save failed: {error} 保存に失敗しました: {error}
|
||||
Save cancelled 保存をキャンセルしました
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2654,3 +2654,9 @@ cameraRaw Color 색상
|
||||
@id filter.sharpen.sharpen 선명하게
|
||||
Hold Alt while drawing for straight segments 그리는 중 Alt를 누르면 직선 구간
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts 드래그하거나 다각형 점 클릭 · 올가미 중 Alt를 누르면 직선 구간 · {add} 추가 · 그리기 전 {sub} 빼기
|
||||
TIFF Options TIFF 옵션
|
||||
Including layers will increase file size. 레이어를 포함하면 파일 크기가 커집니다.
|
||||
Discard Layers and Save a Copy 레이어를 삭제하고 사본 저장
|
||||
Don't show again 다시 표시 안 함
|
||||
Save failed: {error} 저장 실패: {error}
|
||||
Save cancelled 저장이 취소되었습니다
|
||||
|
@@ -2512,3 +2512,9 @@ cameraRaw Whites Brancos
|
||||
Enable {name} Ativar {name}
|
||||
Hold Alt while drawing for straight segments Segure Alt ao desenhar para segmentos retos
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Arraste ou clique nos pontos do polígono · segure Alt durante o laço para segmentos retos · {add} adicionar · {sub} antes de desenhar subtrai
|
||||
TIFF Options Opções de TIFF
|
||||
Including layers will increase file size. Incluir as camadas aumentará o tamanho do arquivo.
|
||||
Discard Layers and Save a Copy Descartar camadas e salvar uma cópia
|
||||
Don't show again Não mostrar novamente
|
||||
Save failed: {error} Falha ao salvar: {error}
|
||||
Save cancelled Salvamento cancelado
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2289,7 +2289,6 @@
|
||||
4 x 4 4 × 4
|
||||
5 x 5 5 × 5
|
||||
Grid: Сетка:
|
||||
|
||||
# Rendering mode and recovery
|
||||
Rendering Mode Режим отрисовки
|
||||
Automatic (recommended) Автоматически (рекомендуется)
|
||||
@@ -2304,10 +2303,8 @@
|
||||
GPU Backend Бэкенд GPU
|
||||
Details Подробности
|
||||
GPU GPU
|
||||
|
||||
Rendering mode Режим отрисовки
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Лассо заморозки: перетащите, чтобы заморозить область; Alt+перетаскивание размораживает (L)
|
||||
|
||||
Shadow Clipping Warning (U) Предупреждение о клиппинге теней (U)
|
||||
Highlight Clipping Warning (O) Предупреждение о клиппинге светов (O)
|
||||
Show Lab Color Readouts Показывать значения Lab
|
||||
@@ -2316,9 +2313,7 @@
|
||||
Show Skin Tone Indicator Показывать ориентир тона кожи
|
||||
Tone Histogram Тональная гистограмма
|
||||
Show Selected Region Показать выделенную область
|
||||
|
||||
cameraRaw Light Свет
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Фильтр Camera Raw ({layer})
|
||||
Aqua Голубой
|
||||
@@ -2350,3 +2345,9 @@ cameraRaw Roughness Неровность
|
||||
Enable {name} Включить {name}
|
||||
Hold Alt while drawing for straight segments Удерживайте Alt при рисовании для прямых отрезков
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Перетащите или щёлкайте точки многоугольника · удерживайте Alt во время лассо для прямых отрезков · {add} добавить · {sub} перед рисованием вычитает
|
||||
TIFF Options Параметры TIFF
|
||||
Including layers will increase file size. Включение слоёв увеличит размер файла.
|
||||
Discard Layers and Save a Copy Удалить слои и сохранить копию
|
||||
Don't show again Больше не показывать
|
||||
Save failed: {error} Не удалось сохранить: {error}
|
||||
Save cancelled Сохранение отменено
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1923,7 +1923,6 @@
|
||||
5 x 5 5 × 5
|
||||
Grid: 网格:
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 冻结套索:拖动以冻结区域,按住 Alt 拖动以解冻 (L)
|
||||
|
||||
Shadow Clipping Warning (U) 阴影剪切警告 (U)
|
||||
Highlight Clipping Warning (O) 高光剪切警告 (O)
|
||||
Show Lab Color Readouts 显示 Lab 颜色读数
|
||||
@@ -1932,9 +1931,7 @@
|
||||
Show Skin Tone Indicator 显示肤色指示线
|
||||
Tone Histogram 色调直方图
|
||||
Show Selected Region 显示选定区域
|
||||
|
||||
cameraRaw Light 光线
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Camera Raw 滤镜({layer})
|
||||
cameraRaw Lights 亮调
|
||||
@@ -2049,7 +2046,6 @@ cameraRaw Blending 混合
|
||||
Use tablet pressure 使用数位板压感
|
||||
Vulkan Vulkan
|
||||
No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. 没有可用于生成选区的路径:请用钢笔工具绘制路径,或在路径面板中选择路径。
|
||||
|
||||
# Layer navigation, copy transforms and cancellable opening (labels added on main since this catalog).
|
||||
Cancel opening 取消打开
|
||||
Cancelled opening {name} 已取消打开 {name}
|
||||
@@ -2060,7 +2056,6 @@ cameraRaw Blending 混合
|
||||
Opening… 正在打开…
|
||||
Reading… 正在读取…
|
||||
Working… 正在处理…
|
||||
|
||||
# Rendering mode and recovery
|
||||
Rendering Mode 渲染模式
|
||||
Automatic (recommended) 自动(推荐)
|
||||
@@ -2081,3 +2076,9 @@ cameraRaw Blending 混合
|
||||
Enable {name} 启用{name}
|
||||
Hold Alt while drawing for straight segments 绘制中按住 Alt 可逐点连接直线
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts 拖动圈选或逐点绘制 · 圈选中按住 Alt 画直线 · {add} 添加 · 起笔前按 {sub} 减去
|
||||
TIFF Options TIFF 选项
|
||||
Including layers will increase file size. 包含图层会增加文件大小。
|
||||
Discard Layers and Save a Copy 扔掉图层并存储副本
|
||||
Don't show again 不再显示
|
||||
Save failed: {error} 存储失败:{error}
|
||||
Save cancelled 已取消存储
|
||||
|
@@ -2002,7 +2002,6 @@
|
||||
4 x 4 4 × 4
|
||||
5 x 5 5 × 5
|
||||
Grid: 格點:
|
||||
|
||||
# Rendering mode and recovery
|
||||
Rendering Mode 渲染模式
|
||||
Automatic (recommended) 自動(建議)
|
||||
@@ -2017,10 +2016,8 @@
|
||||
Advanced 進階
|
||||
GPU Backend GPU 後端
|
||||
Details 詳細資訊
|
||||
|
||||
Rendering mode 渲染模式
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 凍結套索:拖曳以凍結區域,按住 Alt 拖曳以解凍 (L)
|
||||
|
||||
Shadow Clipping Warning (U) 陰影剪裁警告 (U)
|
||||
Highlight Clipping Warning (O) 亮部剪裁警告 (O)
|
||||
Show Lab Color Readouts 顯示 Lab 色彩讀值
|
||||
@@ -2029,9 +2026,7 @@
|
||||
Show Skin Tone Indicator 顯示膚色指示線
|
||||
Tone Histogram 色調直方圖
|
||||
Show Selected Region 顯示選取區域
|
||||
|
||||
cameraRaw Light 光線
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Camera Raw 濾鏡({layer})
|
||||
cameraRaw Lights 亮調
|
||||
@@ -2050,3 +2045,9 @@ cameraRaw Blending 混合
|
||||
Enable {name} 啟用{name}
|
||||
Hold Alt while drawing for straight segments 繪製中按住 Alt 可逐點連接直線
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts 拖曳圈選或逐點繪製 · 圈選中按住 Alt 畫直線 · {add} 增加 · 起筆前按 {sub} 減去
|
||||
TIFF Options TIFF 選項
|
||||
Including layers will increase file size. 包含圖層會增加檔案大小。
|
||||
Discard Layers and Save a Copy 放棄圖層並儲存拷貝
|
||||
Don't show again 不再顯示
|
||||
Save failed: {error} 儲存失敗:{error}
|
||||
Save cancelled 已取消儲存
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -113,6 +113,7 @@ pub mod stroke_trail;
|
||||
pub mod stylus;
|
||||
mod tab_strip;
|
||||
pub mod theme;
|
||||
pub mod tiff_options_ui;
|
||||
mod timeline_ui;
|
||||
pub mod tone;
|
||||
pub mod tool_feedback;
|
||||
@@ -144,10 +145,18 @@ pub use state::{Tool, UiState};
|
||||
/// Decode a file: (document, warnings about anything approximated or dropped).
|
||||
pub type ImportFn = Box<dyn Fn(&str, &[u8]) -> Result<(Document, Vec<String>), String>>;
|
||||
/// Encoder settings chosen in Export As (the file format comes from the name's extension).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ExportSettings {
|
||||
/// JPEG quality 1–100 (None = codec default).
|
||||
pub jpeg_quality: Option<u8>,
|
||||
/// TIFF: keep the layers (Photoshop layer data); `false` is "Discard Layers and Save a Copy".
|
||||
pub tiff_layers: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportSettings {
|
||||
fn default() -> Self {
|
||||
ExportSettings { jpeg_quality: None, tiff_layers: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a document: (file bytes, warnings about anything approximated or dropped).
|
||||
@@ -386,6 +395,8 @@ pub struct PhotocraftApp {
|
||||
pub(crate) prefs_rt: prefs_ui::Runtime,
|
||||
/// Close, Revert or Exit parked behind the unsaved-changes prompt (see `discard_ui`).
|
||||
pub(crate) discard: Option<discard_ui::Prompt>,
|
||||
/// A Save As to a layered TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`).
|
||||
pub(crate) tiff_options: Option<tiff_options_ui::Prompt>,
|
||||
/// Set once the user has agreed to quit, so the resulting close request goes through.
|
||||
pub(crate) allow_close: bool,
|
||||
/// Pen pressure/tilt from the platform (see `stylus`).
|
||||
@@ -468,6 +479,7 @@ impl PhotocraftApp {
|
||||
perf: Default::default(),
|
||||
prefs_rt: Default::default(),
|
||||
discard: None,
|
||||
tiff_options: None,
|
||||
allow_close: false,
|
||||
stylus: Default::default(),
|
||||
background_jobs: false,
|
||||
@@ -748,8 +760,21 @@ impl PhotocraftApp {
|
||||
Some(p) => p,
|
||||
None => self.services.pick_save.as_mut().and_then(|f| f(&suggested)).ok_or("cancelled")?,
|
||||
};
|
||||
// A layered TIFF asks about its layers first (Preferences › File Handling); the save
|
||||
// continues from the prompt.
|
||||
if tiff_options_ui::wants_prompt(self, &path) {
|
||||
tiff_options_ui::park(self, path.clone());
|
||||
return Ok((path, Vec::new()));
|
||||
}
|
||||
self.write_document(path, &ExportSettings::default())
|
||||
}
|
||||
|
||||
/// Encodes the active document with `settings` and writes it to `path`, which becomes the
|
||||
/// document's path. Returns the path and the export warnings (also shown to the user).
|
||||
pub(crate) fn write_document(&mut self, path: String, settings: &ExportSettings) -> Result<(String, Vec<String>), String> {
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
let export = self.services.export.as_ref().ok_or("no exporter configured")?;
|
||||
let (bytes, warnings) = export(&st.doc, &path, &ExportSettings::default())?;
|
||||
let (bytes, warnings) = export(&st.doc, &path, settings)?;
|
||||
let write = self.services.write.as_mut().ok_or("no writer configured")?;
|
||||
write(&path, &bytes)?;
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
@@ -967,6 +992,7 @@ impl eframe::App for PhotocraftApp {
|
||||
dialogs::show(self, &ctx);
|
||||
jobs_ui::dialog(self, &ctx);
|
||||
discard_ui::show(self, &ctx);
|
||||
tiff_options_ui::show(self, &ctx);
|
||||
distort_ui::show(self, &ctx);
|
||||
camera_raw_ui::show(self, &ctx);
|
||||
wide_angle_ui::show(self, &ctx);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! TIFF Options: the prompt File › Save As shows before writing a layered TIFF, as Photoshop
|
||||
//! does when "Ask Before Saving Layered TIFF Files" is on in Preferences › File Handling.
|
||||
//!
|
||||
//! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written
|
||||
//! once answered: with the layers (Photoshop layer data in the file), or flattened ("Discard
|
||||
//! Layers and Save a Copy"). "Don't show again" clears the preference. Cancel drops the save.
|
||||
|
||||
use crate::{ExportSettings, PhotocraftApp};
|
||||
|
||||
/// A save waiting on the TIFF Options answer.
|
||||
pub struct Prompt {
|
||||
/// Where the document goes.
|
||||
pub path: String,
|
||||
/// "Discard Layers and Save a Copy".
|
||||
pub discard_layers: bool,
|
||||
/// Clears `ask_before_saving_layered_tiff` when the save goes ahead.
|
||||
pub dont_ask_again: bool,
|
||||
}
|
||||
|
||||
/// `true` when `path` is a TIFF name (`.tif` / `.tiff`).
|
||||
pub fn is_tiff_path(path: &str) -> bool {
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
name.rsplit_once('.').is_some_and(|(stem, ext)| !stem.is_empty() && (ext.eq_ignore_ascii_case("tif") || ext.eq_ignore_ascii_case("tiff")))
|
||||
}
|
||||
|
||||
/// Whether saving the active document to `path` should go through the prompt: a TIFF that
|
||||
/// would carry layer data, with the preference on.
|
||||
pub fn wants_prompt(app: &PhotocraftApp, path: &str) -> bool {
|
||||
if !is_tiff_path(path) || !app.session.prefs().file_handling.ask_before_saving_layered_tiff {
|
||||
return false;
|
||||
}
|
||||
app.session.active().is_some_and(|st| photocraft_io::tiff_layers::would_write_layers(&st.doc))
|
||||
}
|
||||
|
||||
/// Parks the save behind the prompt.
|
||||
pub fn park(app: &mut PhotocraftApp, path: String) {
|
||||
app.tiff_options = Some(Prompt { path, discard_layers: false, dont_ask_again: false });
|
||||
}
|
||||
|
||||
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let Some(p) = &mut app.tiff_options else { return };
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
let (mut discard, mut dont_ask) = (p.discard_layers, p.dont_ask_again);
|
||||
let modal = egui::Modal::new(egui::Id::new("tiff-options")).show(ctx, |ui| {
|
||||
ui.set_max_width(420.0);
|
||||
ui.label(egui::RichText::new(tl!("TIFF Options")).font(crate::theme::semibold(15.0)));
|
||||
ui.add_space(4.0);
|
||||
crate::widgets::hairline(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.label(tl!("Including layers will increase file size."));
|
||||
ui.add_space(8.0);
|
||||
crate::widgets::checkbox(ui, &mut discard, tl!("Discard Layers and Save a Copy"));
|
||||
crate::widgets::checkbox(ui, &mut dont_ask, tl!("Don't show again"));
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
ok = crate::widgets::primary_button(ui, tl!("OK"), 84.0).clicked();
|
||||
cancel = crate::widgets::secondary_button(ui, tl!("Cancel"), 84.0).clicked();
|
||||
});
|
||||
});
|
||||
cancel |= modal.should_close();
|
||||
p.discard_layers = discard;
|
||||
p.dont_ask_again = dont_ask;
|
||||
if ok || ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
let Some(p) = app.tiff_options.take() else { return };
|
||||
if p.dont_ask_again {
|
||||
app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false);
|
||||
}
|
||||
let settings = ExportSettings { tiff_layers: !p.discard_layers, ..Default::default() };
|
||||
if let Err(e) = app.write_document(p.path.clone(), &settings) {
|
||||
app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]);
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
} else if cancel {
|
||||
app.tiff_options = None;
|
||||
app.ui.status = tl!("Save cancelled").into();
|
||||
app.ui.status_error = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tiff_names() {
|
||||
assert!(is_tiff_path("a.tif"));
|
||||
assert!(is_tiff_path("dir/sub/a.TIFF"));
|
||||
assert!(is_tiff_path("C:\\x\\y.tiff"));
|
||||
assert!(!is_tiff_path("a.tif.psd"));
|
||||
assert!(!is_tiff_path(".tif"));
|
||||
assert!(!is_tiff_path("tif"));
|
||||
assert!(!is_tiff_path("a.png"));
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -7,14 +7,14 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
|
||||
| [File compatibility](#file-compatibility) | 2 | 8 | 17 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 6 | 13 | 11 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 58 of 135 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 135 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 2 | 2 | 7 | 626 of 626 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
|
||||
| [Distribution](#distribution) | 4 | 4 | 3 | |
|
||||
| [Open bugs](#open-bugs) | 12 | 3 | 17 | |
|
||||
| [Open bugs](#open-bugs) | 12 | 4 | 16 | |
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -95,7 +95,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | partial | [#215](https://github.com/storytold/photocraft/issues/215) | PSD Levels/Curves/Shadows-Highlights smart filters render (adjust_cmds.rs adjust_as_filter); Image › Adjustments on a smart object doesn't add one yet |
|
||||
| FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu |
|
||||
| FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc lib.rs:312 SmartObject.transform is an Affine |
|
||||
| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No 37724 / ImageSourceData in crates |
|
||||
| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels are not written (one transparency sample) |
|
||||
| FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | missing | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff.rs:3 'Only the first IFD is read' |
|
||||
| FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No SVG codec or import |
|
||||
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) |
|
||||
@@ -154,9 +154,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 58 of 135** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 57 of 135** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
@@ -258,7 +258,7 @@ Never-crash attribute on 27 of 27 crate roots.
|
||||
| BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | done | [#203](https://github.com/storytold/photocraft/issues/203) | format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFull, rename failure and a read-only dir; locked-target retries, symlink and file-mode preservation covered too |
|
||||
| BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | prefs.rs:196-202; no reader outside prefs.rs (see the prefs audit) |
|
||||
| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | prefs.rs:163,166,229; no reader outside prefs.rs |
|
||||
| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | raw_defaults (prefs.rs:712), ask_before_saving_layered_tiff (prefs.rs:312) have no readers |
|
||||
| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | partial | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults (prefs.rs:712) still has no readers |
|
||||
| BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload |
|
||||
| BUG-204-5 | Recent-files list honours recent_file_count (default 20) | done | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs push_recent caps the list with recent_cap (recent_file_count); menus.rs covers default and custom limits |
|
||||
| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | partial | [#204](https://github.com/storytold/photocraft/issues/204) | The scorecard's prefs audit counts unread fields; no engine test or allowlist yet |
|
||||
|
||||
+2
-2
@@ -65,9 +65,9 @@ note = "prefs.rs:163,166,229; no reader outside prefs.rs"
|
||||
[[item]]
|
||||
id = "BUG-204-3"
|
||||
target = "RAW prefs and ask_before_saving_layered_tiff read, or hidden"
|
||||
status = "missing"
|
||||
status = "partial"
|
||||
issue = 204
|
||||
note = "raw_defaults (prefs.rs:712), ask_before_saving_layered_tiff (prefs.rs:312) have no readers"
|
||||
note = "ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults (prefs.rs:712) still has no readers"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-4"
|
||||
|
||||
@@ -50,9 +50,9 @@ note = "doc lib.rs:312 SmartObject.transform is an Affine"
|
||||
[[item]]
|
||||
id = "FILE-215-7"
|
||||
target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref"
|
||||
status = "missing"
|
||||
status = "done"
|
||||
issue = 215
|
||||
note = "No 37724 / ImageSourceData in crates"
|
||||
note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels are not written (one transparency sample)"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-8"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generates the layered-TIFF fixtures under corpus/layered-tiff (gitignored) with an independent
|
||||
implementation (psdtags + tifffile, BSD-3), so PhotoCraft's reader is checked against a second
|
||||
writer rather than only against itself:
|
||||
|
||||
layered-{le,be}-{8,16}bit.tif 2 layers (one offset, one with transparency), RLE and ZIP
|
||||
channels, an Intel-order and a Motorola-order variant.
|
||||
|
||||
Run with: python scripts/layered_tiff_fixtures.py
|
||||
Needs: pip install psdtags tifffile numpy
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy
|
||||
import tifffile
|
||||
from psdtags import (
|
||||
PsdBlendMode,
|
||||
PsdChannel,
|
||||
PsdChannelId,
|
||||
PsdClippingType,
|
||||
PsdColorSpaceType,
|
||||
PsdCompressionType,
|
||||
PsdFormat,
|
||||
PsdKey,
|
||||
PsdLayer,
|
||||
PsdLayerFlag,
|
||||
PsdLayerMask,
|
||||
PsdLayers,
|
||||
PsdRectangle,
|
||||
PsdString,
|
||||
PsdUserMask,
|
||||
TiffImageSourceData,
|
||||
)
|
||||
|
||||
OUT = Path(__file__).resolve().parent.parent / "corpus" / "layered-tiff"
|
||||
W, H = 6, 4
|
||||
|
||||
|
||||
def gradient(dtype, seed):
|
||||
"""A deterministic W×H×4 RGBA ramp on the 8-bit grid (exact at 16 bits too)."""
|
||||
maxv = 255 if dtype == numpy.uint8 else 65535
|
||||
img = numpy.zeros((H, W, 4), dtype=numpy.uint32)
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
img[y, x] = [(x * 40 + seed) % 256, (y * 60 + seed * 3) % 256, (x * y * 17 + seed) % 256, 255]
|
||||
img = img * (maxv // 255)
|
||||
return img.astype(dtype)
|
||||
|
||||
|
||||
def layer(name, data, left, top, blend, opacity, compression):
|
||||
h, w = data.shape[:2]
|
||||
rect = PsdRectangle(top, left, top + h, left + w)
|
||||
channels = []
|
||||
for cid, idx in [(PsdChannelId.TRANSPARENCY_MASK, 3), (PsdChannelId.CHANNEL0, 0), (PsdChannelId.CHANNEL1, 1), (PsdChannelId.CHANNEL2, 2)]:
|
||||
channels.append(PsdChannel(channelid=cid, compression=compression, data=numpy.ascontiguousarray(data[:, :, idx])))
|
||||
return PsdLayer(
|
||||
name=name,
|
||||
rectangle=rect,
|
||||
channels=channels,
|
||||
mask=PsdLayerMask(),
|
||||
opacity=opacity,
|
||||
blendmode=blend,
|
||||
blending_ranges=(),
|
||||
clipping=PsdClippingType.BASE,
|
||||
flags=PsdLayerFlag.PHOTOSHOP5,
|
||||
info=[PsdString(PsdKey.UNICODE_LAYER_NAME, name)],
|
||||
)
|
||||
|
||||
|
||||
def build(dtype, psdformat):
|
||||
lower = gradient(dtype, 1)
|
||||
upper = gradient(dtype, 7)[:3, :4].copy()
|
||||
# A soft transparency ramp on the upper layer.
|
||||
maxv = 255 if dtype == numpy.uint8 else 65535
|
||||
for y in range(3):
|
||||
for x in range(4):
|
||||
upper[y, x, 3] = (x * 80 % 256) * (maxv // 255)
|
||||
layers = PsdLayers(
|
||||
key=PsdKey.LAYER if dtype == numpy.uint8 else PsdKey.LAYER_16,
|
||||
has_transparency=False,
|
||||
layers=[
|
||||
layer("Background", lower, 0, 0, PsdBlendMode.NORMAL, 255, PsdCompressionType.RLE),
|
||||
layer("Upper é", upper, 1, 1, PsdBlendMode.MULTIPLY, 200, PsdCompressionType.ZIP),
|
||||
],
|
||||
)
|
||||
usermask = PsdUserMask(colorspace=PsdColorSpaceType.RGB, components=(65535, 0, 0, 0), opacity=50)
|
||||
return TiffImageSourceData(name="Layered", psdformat=psdformat, layers=layers, usermask=usermask)
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for dtype, bits in [(numpy.uint8, 8), (numpy.uint16, 16)]:
|
||||
for order, psdformat in [("le", PsdFormat.LE32BIT), ("be", PsdFormat.BE32BIT)]:
|
||||
isd = build(dtype, psdformat)
|
||||
composite = gradient(dtype, 1)
|
||||
path = OUT / f"layered-{order}-{bits}bit.tif"
|
||||
byteorder = "<" if order == "le" else ">"
|
||||
tifffile.imwrite(
|
||||
path,
|
||||
composite,
|
||||
byteorder=byteorder,
|
||||
photometric="rgb",
|
||||
extrasamples=["unassalpha"],
|
||||
resolution=(300, 300),
|
||||
resolutionunit="inch",
|
||||
extratags=[isd.tifftag()],
|
||||
metadata=None,
|
||||
)
|
||||
back = TiffImageSourceData.fromtiff(path)
|
||||
names = [l.name for l in back.layers]
|
||||
assert names == ["Background", "Upper é"], names
|
||||
print(path.name, path.stat().st_size, "bytes", names, back.psdformat)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user