Files
vectorcraft/crates/render/src/encode/tests_psd.rs
T
bflatasticandClaude Opus 5.5 c06c13f577 M4.70: PSD export, flat or with layers, groups for sublayers and text layers named after their text
document.export {format: "psd"} writes layered bitmaps (8 bits per channel),
written from the format's public specification: RGB, CMYK (ink amounts) or
grayscale, the resolution and an embedded profile, PackBits channels, and the
merged image (the flat render). layers (default true) draws each top-level
layer alone as a pixel layer, cropped to what it paints, with its name,
opacity and blend mode; a background colour becomes a Background layer.
maxEditability turns layers and sublayers into groups and every object into a
layer named as the Layers panel names it (text by its text); clipping, masked,
knockout or appearance-carrying layers stay one layer. hiddenLayers writes
hidden layers hidden instead of leaving them out; layers: false writes a flat
image on white. At most 30000 pixels a side.

Export As offers PSD with the psdOptions dialog (the PNG Options rows, then
Color Model, Flat Image / Write Layers with Maximum Editability and Include
Hidden Layers, Embed ICC Profile). Tests read the files back with the psd
crate (MIT OR Apache-2.0, a dev-dependency).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:12:03 +07:00

324 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! PSD export: the header, the layer records (names, bounds, opacity, blend modes, groups,
//! hidden layers), the merged image, the colour models, and a reader's view of the file.
use std::sync::Arc;
use vectorcraft_color::{BlendMode, Color};
use vectorcraft_doc::{Document, LayerColor, Node};
use vectorcraft_geom::Rect;
use vectorcraft_testkit::fixtures::DocBuilder;
use super::jpeg::ColorModel;
use super::psd::{MAX_SIDE, PsdOptions};
use super::*;
/// A 100 × 60 pt document: a red square on Layer 1, and on "Top" a half-opaque blue multiply
/// square overlapping it, a hidden green one and a sublayer "Inner" holding a grey "Corner".
fn doc() -> Document {
let mut b = DocBuilder::new(100.0, 60.0);
b.rect(Rect::new(10.0, 10.0, 40.0, 40.0), Color::rgb8(255, 0, 0), |_| {});
let top = b.layer("Top");
b.rect(Rect::new(30.0, 20.0, 60.0, 50.0), Color::rgb8(0, 0, 255), |n: &mut Node| {
n.opacity = 0.5;
n.blend = BlendMode::Multiply;
});
b.rect(Rect::new(70.0, 5.0, 90.0, 25.0), Color::rgb8(0, 255, 0), |n: &mut Node| n.visible = false);
let corner = b.rect_node(Rect::new(0.0, 0.0, 6.0, 6.0), Color::rgb8(128, 128, 128));
let corner = Node { name: Some("Corner".into()), ..corner };
let inner = Node::layer(b.alloc(), "Inner", LayerColor::Preset(2));
let inner = b.doc.insert(Some(top), usize::MAX, inner).unwrap();
b.doc.insert(Some(inner), usize::MAX, corner).unwrap();
b.build()
}
fn opts(psd: PsdOptions) -> RasterExportOptions {
RasterExportOptions { psd: PsdOptions { embed_icc: false, ..psd }, ..RasterExportOptions::default() }
}
fn export(doc: &Document, o: &RasterExportOptions) -> Vec<u8> {
Renderer::new().export_region(doc, doc.artboards[0].rect, RasterFormat::Psd, o).unwrap()
}
fn u16_at(f: &[u8], at: usize) -> u16 {
u16::from_be_bytes(f[at..at + 2].try_into().unwrap())
}
fn u32_at(f: &[u8], at: usize) -> usize {
u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) as usize
}
/// A layer record as written.
#[derive(Debug)]
struct Rec {
name: String,
/// Top, left, bottom, right.
rect: [i32; 4],
channels: usize,
blend: [u8; 4],
opacity: u8,
hidden: bool,
divider: Option<usize>,
}
/// The file: header channels, colour mode, image resources, layer count and records.
struct Parsed<'a> {
channels: u16,
mode: u16,
resources: &'a [u8],
count: i16,
records: Vec<Rec>,
/// Where the merged image starts.
image: usize,
}
fn parse(f: &[u8]) -> Parsed<'_> {
assert_eq!(&f[..6], b"8BPS\0\x01");
let (channels, mode) = (u16_at(f, 12), u16_at(f, 24));
let mut at = 26;
at += 4 + u32_at(f, at);
let resources = &f[at + 4..at + 4 + u32_at(f, at)];
at += 4 + resources.len();
let image = at + 4 + u32_at(f, at);
let mut out = Parsed { channels, mode, resources, count: 0, records: vec![], image };
if u32_at(f, at) == 0 {
return out;
}
out.count = u16_at(f, at + 8) as i16;
let mut p = at + 10;
for _ in 0..out.count.unsigned_abs() {
let rect: [i32; 4] = std::array::from_fn(|i| u32_at(f, p + i * 4) as i32);
let channels = u16_at(f, p + 16) as usize;
p += 18 + channels * 6;
assert_eq!(&f[p..p + 4], b"8BIM");
let blend: [u8; 4] = f[p + 4..p + 8].try_into().unwrap();
let (opacity, flags) = (f[p + 8], f[p + 10]);
let end = p + 16 + u32_at(f, p + 12);
let mut q = p + 16 + 8;
q += (1 + f[q] as usize).next_multiple_of(4);
let (mut name, mut divider) = (String::new(), None);
while q < end {
assert_eq!(&f[q..q + 4], b"8BIM");
let len = u32_at(f, q + 8);
let data = &f[q + 12..q + 12 + len];
match &f[q + 4..q + 8] {
b"luni" => name = String::from_utf16(&(0..u32_at(data, 0)).map(|i| u16_at(data, 4 + i * 2)).collect::<Vec<_>>()).unwrap(),
b"lsct" => divider = Some(u32_at(data, 0)),
_ => {}
}
q += 12 + len;
}
out.records.push(Rec { name, rect, channels, blend, opacity, hidden: flags & 2 != 0, divider });
p = end;
}
out
}
/// The merged image's planes (`channels` planes of `w` × `h`), unpacked.
fn merged_planes(f: &[u8], channels: usize, w: usize, h: usize) -> Vec<Vec<u8>> {
let data = &f[parse(f).image..];
assert_eq!(u16_at(data, 0), 1, "PackBits");
let mut q = 2 + channels * h * 2;
(0..channels)
.map(|c| {
let mut plane = vec![];
for row in 0..h {
let n = u16_at(data, 2 + (c * h + row) * 2) as usize;
unpack(&data[q..q + n], &mut plane);
q += n;
}
assert_eq!(plane.len(), w * h);
plane
})
.collect()
}
fn unpack(mut src: &[u8], out: &mut Vec<u8>) {
while let Some((&n, rest)) = src.split_first() {
if n < 128 {
out.extend(&rest[..n as usize + 1]);
src = &rest[n as usize + 1..];
} else {
out.extend(std::iter::repeat_n(rest[0], 257 - n as usize));
src = &rest[1..];
}
}
}
/// The composite as a reader sees it.
fn merged(f: &[u8]) -> Vec<u8> {
::psd::Psd::from_bytes(f).expect("a PSD a reader takes").rgba()
}
fn pixel(px: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] {
px[(y * width + x) * 4..][..4].try_into().unwrap()
}
#[test]
fn layers_are_the_top_level_layers_and_the_merged_image_is_the_render() {
let doc = doc();
let file = export(&doc, &opts(PsdOptions::default()));
let p = parse(&file);
assert_eq!((p.channels, p.mode), (4, 3), "RGB with the merged image's transparency");
assert_eq!(p.count, -2, "two layers; negative: the merged alpha is its transparency");
let names: Vec<&str> = p.records.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["Layer 1", "Top"], "bottom first");
let red = &p.records[0];
assert_eq!(red.rect, [10, 10, 40, 40], "cropped to what the layer paints");
assert_eq!((red.channels, red.blend, red.opacity, red.hidden, red.divider), (4, *b"norm", 255, false, None));
assert_eq!(p.records[1].rect, [0, 0, 50, 60], "the blue square and the corner");
// A reader decodes it: the layers, in place, and the merged image.
let psd = ::psd::Psd::from_bytes(&file).unwrap();
assert_eq!((psd.width(), psd.height(), psd.layers().len()), (100, 60, 2));
let red = psd.layer_by_name("Layer 1").unwrap().rgba();
assert_eq!(pixel(&red, 100, 20, 20), [255, 0, 0, 255]);
assert_eq!(pixel(&red, 100, 50, 45)[3], 0, "the blue square isn't on Layer 1");
// The merged image is the flat render: colours on white where transparent, alpha apart.
let img = Renderer::new().render_region_with(&doc, doc.artboards[0].rect, 1.0, &opts(PsdOptions::default()).render_options(RasterFormat::Psd));
let want: Vec<u8> = img.to_straight().as_chunks::<4>().0.iter().flat_map(|p| [on_white(p), [p[3]; 3]].concat()[..4].to_vec()).collect();
assert_eq!(merged(&file), want);
let mixed = pixel(&want, 100, 35, 30);
assert!(mixed[0].abs_diff(128) <= 1 && mixed[1] == 0 && mixed[2] == 0, "red under half-opaque blue multiply: {mixed:?}");
assert_eq!(pixel(&want, 100, 5, 50), [255, 255, 255, 0]);
}
#[test]
fn flat_writes_the_merged_image_alone_on_white() {
let doc = doc();
let o = opts(PsdOptions { layers: false, ..PsdOptions::default() });
let file = export(&doc, &o);
let p = parse(&file);
assert_eq!((p.channels, p.mode, p.count), (3, 3, 0));
assert!(p.records.is_empty());
// The resolution resource: 72 ppi in 16.16 fixed point.
assert_eq!(&p.resources[..6], b"8BIM\x03\xed");
assert_eq!(u32_at(p.resources, 12), 72 << 16);
let img = Renderer::new().render_region_with(&doc, doc.artboards[0].rect, 1.0, &o.render_options(RasterFormat::Psd));
assert_eq!(merged(&file), img.to_straight(), "the composite is the flat render");
// The same flat file from a rendered image.
assert_eq!(o.encode(&img, RasterFormat::Psd).unwrap(), file);
let file = export(&doc, &RasterExportOptions { ppi: 150.0, ..o });
assert_eq!(u32_at(parse(&file).resources, 12), 150 << 16);
assert_eq!(::psd::Psd::from_bytes(&file).unwrap().width(), 208);
}
#[test]
fn maximum_editability_writes_groups_and_an_object_per_layer() {
let doc = doc();
let o = opts(PsdOptions { max_editability: true, ..PsdOptions::default() });
let file = export(&doc, &o);
let p = parse(&file);
let rows: Vec<(&str, Option<usize>)> = p.records.iter().map(|r| (r.name.as_str(), r.divider)).collect();
let rect = doc.layers[0].children().unwrap()[0].display_name();
assert_eq!(
rows,
[
("</Layer group>", Some(3)),
(rect.as_str(), None),
("Layer 1", Some(1)),
("</Layer group>", Some(3)),
(rect.as_str(), None),
("</Layer group>", Some(3)),
("Corner", None),
("Inner", Some(1)),
("Top", Some(1)),
],
"groups end below their layers and open above them; the hidden square is left out"
);
// The object keeps its opacity and blending in its record; groups pass through.
assert_eq!((p.records[4].opacity, p.records[4].blend, p.records[4].rect), (128, *b"mul ", [20, 30, 50, 60]));
assert_eq!((p.records[8].blend, p.records[8].opacity), (*b"pass", 255));
assert_eq!(p.records[6].rect, [0, 0, 6, 6]);
let psd = ::psd::Psd::from_bytes(&file).unwrap();
assert_eq!((psd.layers().len(), psd.groups().len()), (3, 3));
assert_eq!(merged(&file), merged(&export(&doc, &opts(PsdOptions::default()))), "the same merged image");
// Hidden layers: written hidden, drawn as if visible, instead of left out.
let o = opts(PsdOptions { max_editability: true, hidden_layers: true, ..PsdOptions::default() });
let file = export(&doc, &o);
let p = parse(&file);
let hidden: Vec<&Rec> = p.records.iter().filter(|r| r.hidden).collect();
assert_eq!(hidden.len(), 1);
assert_eq!(hidden[0].rect, [5, 70, 25, 90]);
}
#[test]
fn hidden_top_level_layers_are_optional() {
let mut doc = doc();
Arc::make_mut(&mut doc.layers[1]).visible = false;
let names = |o: PsdOptions| parse(&export(&doc, &opts(o))).records.iter().map(|r| (r.name.clone(), r.hidden)).collect::<Vec<_>>();
assert_eq!(names(PsdOptions::default()), [("Layer 1".to_string(), false)]);
assert_eq!(names(PsdOptions { hidden_layers: true, ..PsdOptions::default() }), [("Layer 1".to_string(), false), ("Top".to_string(), true)]);
// Nothing left to write as a layer: no transparency plane either (it would read as a channel).
Arc::make_mut(&mut doc.layers[0]).visible = false;
let file = export(&doc, &opts(PsdOptions::default()));
let p = parse(&file);
assert_eq!((p.channels, p.count), (3, 0));
}
#[test]
fn guides_are_not_layers() {
let mut b = DocBuilder::new(20.0, 20.0);
b.rect(Rect::new(0.0, 0.0, 5.0, 5.0), Color::rgb8(0, 0, 0), |_| {});
b.rect(Rect::new(0.0, 10.0, 20.0, 10.0), Color::rgb8(0, 0, 0), |n: &mut Node| {
if let vectorcraft_doc::NodeKind::Path { guide, .. } = &mut n.kind {
*guide = true;
}
});
let o = opts(PsdOptions { max_editability: true, ..PsdOptions::default() });
assert_eq!(parse(&export(&b.build(), &o)).count, -3, "the group and the square");
}
#[test]
fn a_background_colour_is_the_bottom_layer() {
let doc = doc();
let file = export(&doc, &RasterExportOptions { background: Some([0, 0, 255]), ..opts(PsdOptions::default()) });
let p = parse(&file);
assert_eq!((p.channels, p.count), (3, 3), "opaque: no merged alpha");
assert_eq!((p.records[0].name.as_str(), p.records[0].rect), ("Background", [0, 0, 60, 100]));
let psd = ::psd::Psd::from_bytes(&file).unwrap();
assert!(psd.layer_by_name("Background").unwrap().rgba().chunks(4).all(|p| p == [0, 0, 255, 255]));
assert_eq!(pixel(&merged(&file), 100, 5, 50), [0, 0, 255, 255]);
}
#[test]
fn cmyk_planes_hold_the_inks_inverted_and_gray_is_one_plane() {
let doc = doc();
let cmyk = export(&doc, &opts(PsdOptions { color_model: ColorModel::Cmyk, ..PsdOptions::default() }));
let p = parse(&cmyk);
assert_eq!((p.channels, p.mode, p.count, p.records[0].channels), (5, 4, -2, 5));
let gray = export(&doc, &opts(PsdOptions { color_model: ColorModel::Gray, layers: false, ..PsdOptions::default() }));
let p = parse(&gray);
assert_eq!((p.channels, p.mode), (1, 1));
let px = merged(&gray);
assert_eq!(pixel(&px, 100, 5, 50), [255, 255, 255, 255], "white paper");
assert!((100..160).contains(&px[(20 * 100 + 20) * 4]), "red is a middle grey");
// A CMYK black is black ink alone; no ink is 255.
let mut b = DocBuilder::new(10.0, 10.0);
b.rect(Rect::new(0.0, 0.0, 5.0, 10.0), Color::cmyk(0.0, 0.0, 0.0, 1.0), |_| {});
let doc = b.build();
let file = export(&doc, &opts(PsdOptions { color_model: ColorModel::Cmyk, layers: false, ..PsdOptions::default() }));
let planes = merged_planes(&file, 4, 10, 10);
assert_eq!(planes.iter().map(|p| p[0]).collect::<Vec<_>>(), [255, 255, 255, 0], "black ink");
assert_eq!(planes.iter().map(|p| p[9]).collect::<Vec<_>>(), [255; 4], "no ink");
// Layered: the layer's inks are the same under its alpha.
let file = export(&doc, &opts(PsdOptions { color_model: ColorModel::Cmyk, ..PsdOptions::default() }));
assert_eq!(merged_planes(&file, 5, 10, 10)[3][0], 0);
}
#[test]
fn the_profile_and_the_size_limit() {
let doc = doc();
let file = export(&doc, &RasterExportOptions::default());
let resources = parse(&file).resources;
let icc = resources.windows(6).position(|w| w == b"8BIM\x04\x0f").expect("the profile resource");
assert!(u32_at(resources, icc + 8) > 100);
let o = RasterExportOptions { ppi: 72.0 * 301.0, ..RasterExportOptions::default() };
let err = Renderer::new().export_region(&doc, doc.artboards[0].rect, RasterFormat::Psd, &o).unwrap_err();
assert!(err.contains(&MAX_SIDE.to_string()), "{err}");
}