Files
vectorcraft/crates/render/src/encode/tests_psd.rs
T
dotCooCoo 5ac7d97d85 Guides: ruler guides are document objects on their layers
Ruler guides were a list kept beside the layers. Each guide is now an
object of its layer or sublayer, always directly in it and above the
layer's art. These behave as on main:

- Guides draw on screen only. Exports (SVG, PDF, PNG, PSD), Fit All,
  Select All, the art bounds, clicks and marquees, and a layer's
  Pathfinder and Transform effects leave them out. The Layers panel
  lists them as guide rows of their layer, the highest guide.list
  index first.
- Deleting a layer deletes its guides, and merging layers keeps their
  guides in the merged layer.
- The guide commands number guides as guide.list does, in the order
  they were made.

New: each guide takes an id from the document's object ids. An object
command given a guide's id fails with an error, and node.move cannot
put a guide into a group or out of its layers. A guide made while an
opacity mask, a symbol or a pattern is edited moves to the top
visible, unlocked layer when editing ends.

File format: native files still save the guides in the document's
guides list, in every file version, so older builds read them. Each
entry may now also carry the guide's id, name, locked and hidden
flags; older builds ignore those fields. Opening a file puts each
saved guide on its layer (on the top visible, unlocked layer when the
file names none or a layer it does not have), and each saved guide
counts against the object limit for opening a file. A ruler guide node
that a file holds below a layer's art moves up to the layer's guides,
and one anywhere else (in a group or another object, beside the
layers, or in a symbol) is removed. Ruler guides read from an .ai file
become guide objects on the layer they were on, numbered in the order
the file lists them.
2026-11-11 09:52:28 -08:00

355 lines
15 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");
}
/// Ruler guides on a layer and a sublayer change nothing in the export: the same file with and
/// without Maximum Editability, the same drawn bounds and the same cached size of each layer.
#[test]
fn ruler_guides_are_not_exported() {
use vectorcraft_doc::{NodeId, NodeKind};
let plain = doc();
let mut guided = plain.clone();
let guide = |id, vertical, pos| Node::new(NodeId(id), NodeKind::RulerGuide { vertical, pos, artboard: None });
let (bottom, inner) = (guided.layers[0].id, guided.layers[1].children().unwrap().last().unwrap().id);
guided.insert(Some(bottom), usize::MAX, guide(900, true, 20.0)).unwrap();
guided.insert(Some(bottom), usize::MAX, guide(901, false, 15.0)).unwrap();
guided.insert(Some(inner), usize::MAX, guide(902, true, 3.0)).unwrap();
for psd in [PsdOptions::default(), PsdOptions { max_editability: true, ..PsdOptions::default() }] {
assert_eq!(export(&guided, &opts(psd.clone())), export(&plain, &opts(psd)));
}
assert_eq!(art_bounds(&guided), art_bounds(&plain));
for (g, p) in guided.layers.iter().zip(&plain.layers) {
assert_eq!(crate::drawn_bounds(&guided, g), crate::drawn_bounds(&plain, p));
assert_eq!(crate::lru::node_bytes(g), crate::lru::node_bytes(p));
}
}
#[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}");
}
#[test]
fn long_unicode_name_is_cut_on_a_character_boundary() {
let mut b = DocBuilder::new(100.0, 60.0);
b.layer(&("a".repeat(254) + "😀"));
b.rect(Rect::new(0.0, 0.0, 5.0, 5.0), Color::rgb8(0, 0, 0), |_| {});
let file = export(&b.build(), &opts(PsdOptions::default()));
assert_eq!(parse(&file).records.last().unwrap().name, "a".repeat(254));
}