mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 07:04:10 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a657879a9 |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user