Compare commits

...
5 changed files with 262 additions and 2 deletions

No files matched your search

+2 -1
View File
@@ -102,9 +102,10 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec()));
}
resources.push(version_info_resource(true));
let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth);
let file = PsdFile {
header,
color_mode_data: Vec::new(),
color_mode_data,
resources,
layer_info: None,
layer_info_placement: LayerInfoPlacement::Section,
+4 -1
View File
@@ -1176,9 +1176,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) -
info.padding = None;
}
}
// 32-bit files carry Photoshop's HDR toning records as Color Mode Data; Photoshop will not
// open a 32-bit file without them (#291).
let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth);
let file = PsdFile {
header,
color_mode_data: Vec::new(),
color_mode_data,
resources,
layer_info,
layer_info_placement: placement,
+162
View File
@@ -0,0 +1,162 @@
//! 32-bit-per-channel PSD export (#291): the layout Photoshop requires to open the file.
//!
//! Photoshop 2026 refuses ("the open options are incorrect") any 32-bit PSD whose Color Mode
//! Data lacks the HDR toning records it writes itself. These tests pin the whole structure
//! of a 32-bit export against what Photoshop saves: header depth, toning records, required
//! resources, layer info in an `Lr32` global block with an empty classic layer info, channel
//! compression codes valid for 32-bit data, and a raw big-endian float merged image.
mod common;
use common::Features;
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::Document;
use photocraft_geom::Size;
use photocraft_io::*;
use photocraft_psd::{Compression, LayerInfoPlacement, PsdFile, hdr};
fn be32(b: &[u8], at: usize) -> u32 {
u32::from_be_bytes(b[at..at + 4].try_into().unwrap())
}
/// Walks the raw file and returns (classic layer info length, keys of the global blocks).
fn raw_layer_section(bytes: &[u8]) -> Option<(u32, Vec<[u8; 4]>)> {
let mut p = 26;
p += 4 + be32(bytes, p) as usize; // color mode data
p += 4 + be32(bytes, p) as usize; // image resources
let lm_len = be32(bytes, p) as usize;
p += 4;
if lm_len == 0 {
return None;
}
let end = p + lm_len;
let li_len = be32(bytes, p);
p += 4 + li_len as usize;
p += 4 + be32(bytes, p) as usize; // global layer mask
let mut keys = Vec::new();
while p + 12 <= end {
assert!(matches!(&bytes[p..p + 4], b"8BIM" | b"8B64"), "global block signature at {p}");
keys.push(bytes[p + 4..p + 8].try_into().unwrap());
let n = be32(bytes, p + 8) as usize;
// Global blocks are padded to 4 bytes.
p += 12 + n.div_ceil(4) * 4;
}
Some((li_len, keys))
}
/// Asserts the structure of a 32-bit PSD written by our exporter.
fn assert_32bit_layout(name: &str, bytes: &[u8]) {
let f = PsdFile::from_bytes(bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(f.header.depth, 32, "{name}: depth");
assert_eq!(f.color_mode_data, hdr::default_toning_data(), "{name}: HDR toning color mode data");
for id in [1005u16, 1057] {
assert!(f.resource(id).is_some(), "{name}: resource {id} missing");
}
assert_eq!(f.has_real_merged_data(), Some(true), "{name}: 1057 real merged data");
// Merged image: raw big-endian IEEE floats, finite.
assert_eq!(f.image_data.compression, Compression::Raw, "{name}: merged compression");
let n = f.header.width as usize * f.header.height as usize * usize::from(f.header.channels);
assert_eq!(f.image_data.data.len(), n * 4, "{name}: merged size");
assert!(f.image_data.data.as_chunks::<4>().0.iter().all(|c| f32::from_be_bytes(*c).is_finite()), "{name}: non-finite merged sample");
if f.layers().is_empty() {
return;
}
// Layers live in an `Lr32` global block; the classic layer info is empty.
assert!(
matches!(&f.layer_info_placement, LayerInfoPlacement::GlobalBlock { key, signature, .. } if key == b"Lr32" && signature == b"8BIM"),
"{name}: placement {:?}",
f.layer_info_placement
);
let (li_len, keys) = raw_layer_section(bytes).unwrap_or_else(|| panic!("{name}: no layer section"));
assert_eq!(li_len, 0, "{name}: classic layer info length");
assert_eq!(keys.first(), Some(b"Lr32"), "{name}: global blocks {keys:?}");
// Channel data: raw or ZIP (RLE is not valid for 32-bit samples), decodable.
for (i, rec) in f.layers().iter().enumerate() {
for ch in &rec.channels {
let c = ch.compression.unwrap_or(Compression::Raw);
assert!(matches!(c, Compression::Raw | Compression::Zip | Compression::ZipPrediction), "{name}: layer {i} channel {} uses {c:?}", ch.id);
rec.decode_channel(ch.id, 32, f.header.version).unwrap_or_else(|e| panic!("{name}: layer {i} channel {}: {e}", ch.id));
}
}
let errs = common::strict_block_errors(bytes);
assert!(errs.is_empty(), "{name}: {errs:?}");
}
fn export_psd(doc: &Document) -> Vec<u8> {
export(doc, "t.psd", &ExportOptions::default()).unwrap().bytes
}
#[test]
fn flat_32bit_export_layout() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
let doc = Document::new("flat", Size::new(9, 7), mode, SampleType::F32);
assert_32bit_layout(&format!("flat {mode:?}"), &export_psd(&doc));
}
}
#[test]
fn layered_32bit_export_layout() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for (label, features) in [("pixels", Features::PIXELS), ("all", Features::ALL)] {
let doc = common::gen_doc(mode, SampleType::F32, features);
assert_32bit_layout(&format!("{mode:?} {label}"), &export_psd(&doc));
}
}
}
#[test]
fn only_32bit_exports_carry_toning_data() {
for depth in [SampleType::U8, SampleType::U16] {
let doc = common::gen_doc(ColorMode::Rgb, depth, Features::PIXELS);
let f = PsdFile::from_bytes(&export_psd(&doc)).unwrap();
assert!(f.color_mode_data.is_empty(), "{depth:?}");
}
}
#[test]
fn layered_32bit_export_reimports_render_equal() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
let doc = common::gen_doc(mode, SampleType::F32, Features::ALL);
let back = import("t.psd", &export_psd(&doc)).unwrap().document;
let (a, b) = (photocraft_compose::flatten(&doc).px, photocraft_compose::flatten(&back).px);
assert_eq!(a.len(), b.len());
let d = common::max_diff(&a, &b);
assert!(d <= 1e-4, "{mode:?}: render differs by {d}");
}
}
/// Every 32-bit Photoshop oracle: its Color Mode Data is the record we write, and our export
/// of it has the 32-bit layout, re-imports and renders the same.
#[cfg(feature = "corpus")]
#[test]
fn photoshop_32bit_corpus_round_trip() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/adjustments");
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display());
let mut files = Vec::new();
for dir in ["rgb32", "gray32"] {
for e in std::fs::read_dir(root.join(dir)).unwrap() {
let p = e.unwrap().path();
if p.extension().is_some_and(|x| x == "psd") {
files.push(p);
}
}
}
files.sort();
assert!(files.len() >= 20, "only {} 32-bit oracles", files.len());
for p in &files {
let name = p.strip_prefix(&root).unwrap().display().to_string();
let bytes = std::fs::read(p).unwrap();
let ps = PsdFile::from_bytes(&bytes).unwrap();
assert_eq!(ps.header.depth, 32, "{name}");
assert_eq!(ps.color_mode_data, hdr::default_toning_data(), "{name}: Photoshop's toning record differs from ours");
let doc = import(&name, &bytes).unwrap().document;
let out = export_psd(&doc);
assert_32bit_layout(&name, &out);
let back = import(&name, &out).unwrap().document;
assert_eq!(doc.layer_count(), back.layer_count(), "{name}: layer count");
let (a, b) = (photocraft_compose::flatten(&doc).px, photocraft_compose::flatten(&back).px);
assert_eq!(a.len(), b.len(), "{name}");
let d = common::max_diff(&a, &b);
assert!(d <= 1e-4, "{name}: render differs by {d}");
}
}
+93
View File
@@ -0,0 +1,93 @@
//! HDR toning settings: the Color Mode Data of 32-bit-per-channel documents.
//!
//! Adobe's specification only documents the Color Mode Data section for
//! indexed and duotone images, but every 32-bit file Photoshop saves carries
//! an `hdrt` record there (the defaults of Image › Mode › 8/16 Bits/Channel ›
//! HDR Toning), and current versions append an `hdra` record. Photoshop 2026
//! refuses to open a 32-bit file whose Color Mode Data is empty or not in this
//! form ("the open options are incorrect", #291), so writers must emit it.
//!
//! The layout below was established by observing Photoshop-saved files (the
//! `photocraft-corpus` oracles); fields whose meaning is not known are written
//! with the values Photoshop writes for a new document.
use crate::io::WriteExt;
/// Key of the HDR toning record.
pub const TONING_KEY: &[u8; 4] = b"hdrt";
/// Key of the record current Photoshop versions append after the toning record.
pub const EXTRA_KEY: &[u8; 4] = b"hdra";
/// `true` if `data` (a file's Color Mode Data) starts with an HDR toning record.
pub fn is_toning_data(data: &[u8]) -> bool {
data.starts_with(TONING_KEY)
}
/// The Color Mode Data Photoshop writes for a new 32-bit document: a version 3
/// `hdrt` record (method 2, preset "Linear", an identity toning curve) followed
/// by a version 6 `hdra` record. 110 bytes.
pub fn default_toning_data() -> Vec<u8> {
let mut out = Vec::with_capacity(110);
// `hdrt`, version 3.
out.put(TONING_KEY);
out.put_u32(3);
out.put(&0.23f32.to_be_bytes());
out.put_u32(2); // toning method
// Preset name: Unicode string (UTF-16 code units including the terminator).
let name: Vec<u16> = "Linear".encode_utf16().chain(std::iter::once(0)).collect();
out.put_u32(name.len() as u32);
for u in name {
out.put_u16(u);
}
// Toning curve: two points, (0, 0) and (255, 255), both corners.
out.put_u16(2);
out.put_u16(2);
for (input, output) in [(0u16, 0u16), (255, 255)] {
out.put_u16(output);
out.put_u16(input);
}
out.put(&[1, 1]);
out.put(&[0; 8]);
out.put(&16.0f32.to_be_bytes());
out.put_u32(1);
out.put_u32(0);
out.put(&1.0f32.to_be_bytes());
// `hdra`, version 6.
out.put(EXTRA_KEY);
out.put_u32(6);
out.put_u32(0);
out.put(&20.0f32.to_be_bytes());
out.put(&30.0f32.to_be_bytes());
out.put_u32(0);
out.put_u32(0);
out.put(&1.0f32.to_be_bytes());
out.put(&[0; 6]);
out
}
/// The Color Mode Data a writer should emit for a file of `depth` bits per
/// channel when it has nothing of its own to keep: the default HDR toning
/// records for 32-bit files, nothing otherwise (indexed and duotone data come
/// from the document).
pub fn color_mode_data_for_depth(depth: u16) -> Vec<u8> {
if depth == 32 { default_toning_data() } else { Vec::new() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_record_layout() {
let d = default_toning_data();
assert_eq!(d.len(), 110);
assert!(is_toning_data(&d));
assert_eq!(&d[72..76], EXTRA_KEY);
// Version fields.
assert_eq!(&d[4..8], &3u32.to_be_bytes());
assert_eq!(&d[76..80], &6u32.to_be_bytes());
assert!(color_mode_data_for_depth(8).is_empty());
assert!(color_mode_data_for_depth(16).is_empty());
assert_eq!(color_mode_data_for_depth(32), d);
}
}
+1
View File
@@ -49,6 +49,7 @@ pub mod error;
pub mod file;
pub mod filter_effects;
pub mod grd;
pub mod hdr;
pub mod header;
pub mod image_data;
mod io;