mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ed4eceb837
commit
c06c13f577
@@ -3,12 +3,13 @@
|
||||
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or
|
||||
//! a palette image ([`quantize`]) as PNG-8 or [`gif`], [`tiff`] (RGB, CMYK or grey, LZW, either byte
|
||||
//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`]; [`web`] adds what Save for Web optimises
|
||||
//! further (web snap, colour table edits, lossy GIF, comments).
|
||||
//! further (web snap, colour table edits, lossy GIF, comments); [`psd`] writes layered bitmaps.
|
||||
|
||||
pub mod bmp;
|
||||
pub mod gif;
|
||||
pub mod jpeg;
|
||||
pub mod png;
|
||||
pub mod psd;
|
||||
pub mod quantize;
|
||||
pub mod tga;
|
||||
pub mod tiff;
|
||||
@@ -31,6 +32,8 @@ pub enum RasterFormat {
|
||||
Tiff,
|
||||
Bmp,
|
||||
Tga,
|
||||
/// Layered bitmap (.psd).
|
||||
Psd,
|
||||
}
|
||||
|
||||
/// How a raster export renders and encodes.
|
||||
@@ -55,6 +58,8 @@ pub struct RasterExportOptions {
|
||||
pub bmp: bmp::BmpOptions,
|
||||
/// Targa depth.
|
||||
pub tga: tga::TgaOptions,
|
||||
/// PSD colour model, layers and profile.
|
||||
pub psd: psd::PsdOptions,
|
||||
}
|
||||
|
||||
impl Default for RasterExportOptions {
|
||||
@@ -70,6 +75,7 @@ impl Default for RasterExportOptions {
|
||||
tiff: tiff::TiffOptions::default(),
|
||||
bmp: bmp::BmpOptions::default(),
|
||||
tga: tga::TgaOptions::default(),
|
||||
psd: psd::PsdOptions::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,9 +87,10 @@ impl RasterExportOptions {
|
||||
}
|
||||
|
||||
/// What the renderer draws for `format`: template layers left out, over the background
|
||||
/// (white for a JPEG without one).
|
||||
/// (white for a JPEG or a flat PSD without one).
|
||||
pub fn render_options(&self, format: RasterFormat) -> RenderOptions {
|
||||
let background = self.background.or((format == RasterFormat::Jpeg).then_some([255; 3]));
|
||||
let flat = format == RasterFormat::Jpeg || (format == RasterFormat::Psd && !self.psd.layers);
|
||||
let background = self.background.or(flat.then_some([255; 3]));
|
||||
RenderOptions {
|
||||
background: background.map(|[r, g, b]| [r, g, b, 255]),
|
||||
skip_templates: true,
|
||||
@@ -92,8 +99,8 @@ impl RasterExportOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a rendered image as `format`. A CMYK JPEG separates the screen colours;
|
||||
/// [`Renderer::export_region`] draws the inks instead.
|
||||
/// Encode a rendered image as `format`. A CMYK JPEG separates the screen colours, and a PSD is
|
||||
/// flat; [`Renderer::export_region`] draws the inks and the layers instead.
|
||||
pub fn encode(&self, img: &Rendered, format: RasterFormat) -> Result<Vec<u8>, String> {
|
||||
match format {
|
||||
RasterFormat::Png => {
|
||||
@@ -117,6 +124,7 @@ impl RasterExportOptions {
|
||||
RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff),
|
||||
RasterFormat::Bmp => bmp::encode(&img.to_straight(), img.width, img.height, self.ppi, &self.bmp, &self.palette),
|
||||
RasterFormat::Tga => tga::encode(&img.to_straight(), img.width, img.height, &self.tga),
|
||||
RasterFormat::Psd => psd::encode(img, self.ppi, &self.psd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +141,11 @@ impl RasterExportOptions {
|
||||
impl Renderer {
|
||||
/// Render `region` of `doc` (an artboard or any rect) as exported and encode it as `format`.
|
||||
/// Callers check the size first ([`crate::raster_size`]). A CMYK JPEG or TIFF is drawn as ink
|
||||
/// amounts ([`Renderer::render_region_inks`]).
|
||||
/// amounts ([`Renderer::render_region_inks`]); a PSD with its layers ([`psd::export`]).
|
||||
pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result<Vec<u8>, String> {
|
||||
if format == RasterFormat::Psd {
|
||||
return psd::export(self, doc, region, opts);
|
||||
}
|
||||
if opts.cmyk(format) {
|
||||
let (w, h) = crate::region_pixels(region, opts.scale());
|
||||
let inks = self.render_region_inks(doc, region, opts.scale(), &opts.render_options(format));
|
||||
@@ -199,6 +210,8 @@ mod tests_jpeg;
|
||||
#[cfg(test)]
|
||||
mod tests_palette;
|
||||
#[cfg(test)]
|
||||
mod tests_psd;
|
||||
#[cfg(test)]
|
||||
mod tests_tga;
|
||||
#[cfg(test)]
|
||||
mod tests_tiff;
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
//! PSD export: a layered bitmap at 8 bits per channel, written from the format's public
|
||||
//! specification. The file holds the header, the resolution and colour profile resources, the
|
||||
//! layer records and their channels (PackBits row by row; groups as section dividers), then the
|
||||
//! merged image, which readers that skip layers show. RGB, CMYK (stored as 255 − ink, as the
|
||||
//! format keeps inks) or grayscale.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{BlendMode, cms};
|
||||
use vectorcraft_doc::{Document, Knockout, LayerColor, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::jpeg::{self, ColorModel};
|
||||
use super::tiff::packbits;
|
||||
use super::{RasterExportOptions, RasterFormat, on_white};
|
||||
use crate::{RenderOptions, Rendered, Renderer, fx};
|
||||
|
||||
/// Most pixels a side of a PSD can have (larger images need the format's large-document variant).
|
||||
pub const MAX_SIDE: u32 = 30_000;
|
||||
|
||||
/// The name of the record that closes a group (the format's convention).
|
||||
const GROUP_END: &str = "</Layer group>";
|
||||
|
||||
/// The PSD options of a raster export.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PsdOptions {
|
||||
pub color_model: ColorModel,
|
||||
/// Write Layers: each top-level layer becomes a pixel layer. Off: one flat image, on white where
|
||||
/// nothing is drawn.
|
||||
pub layers: bool,
|
||||
/// With `layers`: layers and sublayers become groups, and every object in them a pixel layer of
|
||||
/// its own, named as the Layers panel names it (text by its text). A layer whose look comes from
|
||||
/// its objects together (a clipping mask, an opacity mask, its own appearance, knockout) stays
|
||||
/// one pixel layer.
|
||||
pub max_editability: bool,
|
||||
/// With `layers`: hidden layers (and hidden objects) are written as hidden layers instead of
|
||||
/// being left out.
|
||||
pub hidden_layers: bool,
|
||||
/// Embed the colour profile of the colour model.
|
||||
pub embed_icc: bool,
|
||||
}
|
||||
|
||||
impl Default for PsdOptions {
|
||||
fn default() -> Self {
|
||||
Self { color_model: ColorModel::Rgb, layers: true, max_editability: false, hidden_layers: false, embed_icc: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// The format's colour mode number.
|
||||
fn mode(model: ColorModel) -> u16 {
|
||||
match model {
|
||||
ColorModel::Gray => 1,
|
||||
ColorModel::Rgb => 3,
|
||||
ColorModel::Cmyk => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The format's key for a blend mode.
|
||||
fn blend_key(b: BlendMode) -> [u8; 4] {
|
||||
*match b {
|
||||
BlendMode::Normal => b"norm",
|
||||
BlendMode::Darken => b"dark",
|
||||
BlendMode::Multiply => b"mul ",
|
||||
BlendMode::ColorBurn => b"idiv",
|
||||
BlendMode::Lighten => b"lite",
|
||||
BlendMode::Screen => b"scrn",
|
||||
BlendMode::ColorDodge => b"div ",
|
||||
BlendMode::Overlay => b"over",
|
||||
BlendMode::SoftLight => b"sLit",
|
||||
BlendMode::HardLight => b"hLit",
|
||||
BlendMode::Difference => b"diff",
|
||||
BlendMode::Exclusion => b"smud",
|
||||
BlendMode::Hue => b"hue ",
|
||||
BlendMode::Saturation => b"sat ",
|
||||
BlendMode::Color => b"colr",
|
||||
BlendMode::Luminosity => b"lum ",
|
||||
}
|
||||
}
|
||||
|
||||
/// An opacity 0–1 as a byte.
|
||||
fn opacity_byte(o: f32) -> u8 {
|
||||
(o.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// Pixels in `model`'s planes, then alpha when `alpha`. `px` is straight RGBA; `inks` the CMYK ink
|
||||
/// amounts of the same pixels, as drawn on white paper (CMYK only). The merged image (`matte`) is
|
||||
/// stored on white, as readers that skip alpha show it; layers keep their colours straight under
|
||||
/// their alpha.
|
||||
fn planes(model: ColorModel, px: &[u8], inks: Option<&[u8]>, matte: bool, alpha: bool) -> Vec<Vec<u8>> {
|
||||
let rgba = px.as_chunks::<4>().0;
|
||||
let rgb = |p: &[u8; 4]| if matte { on_white(p) } else { [p[0], p[1], p[2]] };
|
||||
let mut out: Vec<Vec<u8>> = match model {
|
||||
ColorModel::Rgb => (0..3).map(|c| rgba.iter().map(|p| rgb(p)[c]).collect()).collect(),
|
||||
ColorModel::Gray => {
|
||||
let gray = jpeg::to_gray();
|
||||
vec![rgba.iter().map(|p| gray(rgb(p))).collect()]
|
||||
}
|
||||
ColorModel::Cmyk => {
|
||||
let inks = inks.map_or(&[][..], |i| i.as_chunks::<4>().0);
|
||||
(0..4)
|
||||
.map(|c| {
|
||||
inks.iter()
|
||||
.zip(rgba)
|
||||
.map(|(k, p)| {
|
||||
// Inks are drawn premultiplied by coverage: a layer's are straightened.
|
||||
let a = u32::from(p[3]);
|
||||
let ink = if matte || a == 0 { k[c] } else { ((u32::from(k[c]) * 255 + a / 2) / a).min(255) as u8 };
|
||||
255 - ink
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
if alpha {
|
||||
out.push(rgba.iter().map(|p| p[3]).collect());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rows of `plane` (`width` bytes each) PackBits-compressed one by one: their byte counts go to
|
||||
/// `counts`, the rows to `data`.
|
||||
fn rle(plane: &[u8], width: usize, counts: &mut Vec<u8>, data: &mut Vec<u8>) {
|
||||
for row in plane.chunks(width.max(1)) {
|
||||
let at = data.len();
|
||||
packbits(row, data);
|
||||
// A row of at most MAX_SIDE bytes packs into fewer than 65536.
|
||||
counts.extend(u16::try_from(data.len() - at).unwrap_or(u16::MAX).to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// One layer record (records go bottom first).
|
||||
struct Record {
|
||||
name: String,
|
||||
/// Top, left, bottom, right in canvas pixels.
|
||||
rect: [i32; 4],
|
||||
/// Channel id (−1: transparency) and its data, compression code first.
|
||||
channels: Vec<(i16, Vec<u8>)>,
|
||||
blend: [u8; 4],
|
||||
opacity: u8,
|
||||
hidden: bool,
|
||||
/// Section divider: [`OPEN_GROUP`] or [`END_GROUP`].
|
||||
divider: Option<u32>,
|
||||
}
|
||||
|
||||
/// Section divider kinds.
|
||||
const OPEN_GROUP: u32 = 1;
|
||||
const END_GROUP: u32 = 3;
|
||||
|
||||
impl Record {
|
||||
/// A record without pixels: an empty layer, or a group's divider.
|
||||
fn empty(name: String, colors: usize) -> Self {
|
||||
Self {
|
||||
name,
|
||||
rect: [0; 4],
|
||||
// Raw (code 0) and no data.
|
||||
channels: std::iter::once(-1).chain(0..colors as i16).map(|id| (id, vec![0, 0])).collect(),
|
||||
blend: *b"norm",
|
||||
opacity: 255,
|
||||
hidden: false,
|
||||
divider: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A pixel layer of `planes` (colours, then alpha), `width` pixels wide, at `left`, `top`.
|
||||
fn pixels(name: String, planes: &[Vec<u8>], width: u32, height: u32, left: u32, top: u32) -> Self {
|
||||
let Some((alpha, colors)) = planes.split_last() else { return Self::empty(name, 0) };
|
||||
let channels = std::iter::once((-1, alpha))
|
||||
.chain(colors.iter().enumerate().map(|(i, p)| (i as i16, p)))
|
||||
.map(|(id, plane)| {
|
||||
let (mut counts, mut data) = (vec![0, 1], vec![]);
|
||||
rle(plane, width as usize, &mut counts, &mut data);
|
||||
counts.extend(data);
|
||||
(id, counts)
|
||||
})
|
||||
.collect();
|
||||
let [l, t, w, h] = [left, top, width, height].map(|v| i32::try_from(v).unwrap_or(i32::MAX));
|
||||
Self { channels, rect: [t, l, t.saturating_add(h), l.saturating_add(w)], ..Self::empty(name, 0) }
|
||||
}
|
||||
|
||||
/// A layer of one colour (`values`: a value per plane, colours then alpha) over the whole
|
||||
/// `width` × `height` canvas, without drawing its pixels first: one packed row, repeated.
|
||||
fn solid(name: String, values: &[u8], width: u32, height: u32) -> Self {
|
||||
let Some((alpha, colors)) = values.split_last() else { return Self::empty(name, 0) };
|
||||
let channels = std::iter::once((-1, *alpha))
|
||||
.chain(colors.iter().enumerate().map(|(i, v)| (i as i16, *v)))
|
||||
.map(|(id, v)| {
|
||||
let (mut count, mut row) = (vec![], vec![]);
|
||||
rle(&vec![v; width as usize], width as usize, &mut count, &mut row);
|
||||
let mut data = vec![0, 1];
|
||||
data.extend(count.repeat(height as usize));
|
||||
data.extend(row.repeat(height as usize));
|
||||
(id, data)
|
||||
})
|
||||
.collect();
|
||||
let [w, h] = [width, height].map(|v| i32::try_from(v).unwrap_or(i32::MAX));
|
||||
Self { channels, rect: [0, 0, h, w], ..Self::empty(name, 0) }
|
||||
}
|
||||
|
||||
fn write(&self, out: &mut Vec<u8>) -> Result<(), String> {
|
||||
for v in self.rect {
|
||||
out.extend(v.to_be_bytes());
|
||||
}
|
||||
out.extend((self.channels.len() as u16).to_be_bytes());
|
||||
for (id, data) in &self.channels {
|
||||
out.extend(id.to_be_bytes());
|
||||
out.extend(len32(data.len())?.to_be_bytes());
|
||||
}
|
||||
out.extend(b"8BIM");
|
||||
out.extend(self.blend);
|
||||
out.push(self.opacity);
|
||||
// Clipping: base.
|
||||
out.push(0);
|
||||
// Flags: bit 1 hidden, bit 3 "bit 4 is set", bit 4 no pixels that matter (dividers).
|
||||
out.push(0x08 | if self.hidden { 0x02 } else { 0 } | if self.divider.is_some() { 0x10 } else { 0 });
|
||||
out.push(0);
|
||||
// Extra data: no layer mask, no blending ranges, the name, then tagged blocks.
|
||||
let mut extra = vec![0; 8];
|
||||
// The name as a Pascal string of its ASCII, padded to a multiple of 4 bytes; readers take
|
||||
// the Unicode name that follows.
|
||||
let ascii: Vec<u8> = self.name.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'_' }).take(255).collect();
|
||||
extra.push(ascii.len() as u8);
|
||||
extra.extend(ascii);
|
||||
extra.resize(extra.len().next_multiple_of(4), 0);
|
||||
let units: Vec<u16> = self.name.encode_utf16().take(255).collect();
|
||||
let mut luni = (units.len() as u32).to_be_bytes().to_vec();
|
||||
luni.extend(units.iter().flat_map(|u| u.to_be_bytes()));
|
||||
block(&mut extra, b"luni", &luni)?;
|
||||
if let Some(kind) = self.divider {
|
||||
block(&mut extra, b"lsct", &[&kind.to_be_bytes()[..], b"8BIM", &self.blend].concat())?;
|
||||
}
|
||||
out.extend(len32(extra.len())?.to_be_bytes());
|
||||
out.extend(extra);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn too_large() -> String {
|
||||
"the image is too large for PSD (at most 4 GB): lower the resolution".into()
|
||||
}
|
||||
|
||||
fn len32(n: usize) -> Result<u32, String> {
|
||||
u32::try_from(n).map_err(|_| too_large())
|
||||
}
|
||||
|
||||
/// A tagged block of additional layer information, padded to an even length.
|
||||
fn block(out: &mut Vec<u8>, key: &[u8; 4], data: &[u8]) -> Result<(), String> {
|
||||
let len = data.len().next_multiple_of(2);
|
||||
out.extend(b"8BIM");
|
||||
out.extend(key);
|
||||
out.extend(len32(len)?.to_be_bytes());
|
||||
out.extend(data);
|
||||
out.resize(out.len() + len - data.len(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An image resource block (no name), padded to an even length.
|
||||
fn resource(out: &mut Vec<u8>, id: u16, data: &[u8]) -> Result<(), String> {
|
||||
out.extend(b"8BIM");
|
||||
out.extend(id.to_be_bytes());
|
||||
// An empty Pascal name, padded to an even length.
|
||||
out.extend([0, 0]);
|
||||
out.extend(len32(data.len())?.to_be_bytes());
|
||||
out.extend(data);
|
||||
if data.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything a PSD file holds.
|
||||
struct File {
|
||||
width: u32,
|
||||
height: u32,
|
||||
model: ColorModel,
|
||||
/// The merged image's planes: colours, then alpha when `alpha`.
|
||||
merged: Vec<Vec<u8>>,
|
||||
alpha: bool,
|
||||
/// Bottom first.
|
||||
records: Vec<Record>,
|
||||
ppi: f64,
|
||||
icc: Option<Arc<[u8]>>,
|
||||
}
|
||||
|
||||
/// Refuse a size the format can't store.
|
||||
fn check_size(w: u32, h: u32) -> Result<(), String> {
|
||||
if w > MAX_SIDE || h > MAX_SIDE {
|
||||
return Err(format!("{w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl File {
|
||||
fn write(&self) -> Result<Vec<u8>, String> {
|
||||
let (w, h) = (self.width, self.height);
|
||||
check_size(w, h)?;
|
||||
let n = w as usize * h as usize;
|
||||
if n == 0 || self.merged.is_empty() || self.merged.iter().any(|p| p.len() != n) {
|
||||
return Err("PSD encoding failed: the pixel buffer doesn't match the image size".into());
|
||||
}
|
||||
let mut out = Vec::with_capacity(n * self.merged.len() / 2 + 1024);
|
||||
// Header: signature, version 1, reserved, channels, size, depth, colour mode.
|
||||
out.extend(b"8BPS");
|
||||
out.extend(1u16.to_be_bytes());
|
||||
out.extend([0; 6]);
|
||||
out.extend((self.merged.len() as u16).to_be_bytes());
|
||||
out.extend(h.to_be_bytes());
|
||||
out.extend(w.to_be_bytes());
|
||||
out.extend(8u16.to_be_bytes());
|
||||
out.extend(mode(self.model).to_be_bytes());
|
||||
// No colour mode data.
|
||||
out.extend(0u32.to_be_bytes());
|
||||
// Image resources: the resolution (16.16 fixed point, pixels per inch shown in inches), the profile.
|
||||
let ppi = if self.ppi.is_finite() && self.ppi > 0.0 { self.ppi } else { 72.0 };
|
||||
let fixed = (ppi.min(30_000.0) * 65536.0).round() as u32;
|
||||
let res = [fixed.to_be_bytes(), [0, 1, 0, 1], fixed.to_be_bytes(), [0, 1, 0, 1]].concat();
|
||||
let mut resources = vec![];
|
||||
resource(&mut resources, 1005, &res)?;
|
||||
if let Some(icc) = &self.icc {
|
||||
resource(&mut resources, 1039, icc)?;
|
||||
}
|
||||
out.extend(len32(resources.len())?.to_be_bytes());
|
||||
out.extend(resources);
|
||||
self.write_layers(&mut out)?;
|
||||
// The merged image: PackBits, every row's byte count first.
|
||||
out.extend(1u16.to_be_bytes());
|
||||
let (mut counts, mut data) = (Vec::with_capacity(self.merged.len() * h as usize * 2), vec![]);
|
||||
for p in &self.merged {
|
||||
rle(p, w as usize, &mut counts, &mut data);
|
||||
}
|
||||
out.extend(counts);
|
||||
out.extend(data);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The layer and mask information section.
|
||||
fn write_layers(&self, out: &mut Vec<u8>) -> Result<(), String> {
|
||||
if self.records.is_empty() {
|
||||
out.extend(0u32.to_be_bytes());
|
||||
return Ok(());
|
||||
}
|
||||
let count = i16::try_from(self.records.len()).map_err(|_| "too many layers for PSD (at most 32767)".to_string())?;
|
||||
// A negative count: the merged image's alpha is its transparency.
|
||||
let mut info = (if self.alpha { -count } else { count }).to_be_bytes().to_vec();
|
||||
for r in &self.records {
|
||||
r.write(&mut info)?;
|
||||
}
|
||||
for (_, data) in self.records.iter().flat_map(|r| &r.channels) {
|
||||
info.extend(data);
|
||||
}
|
||||
info.resize(info.len().next_multiple_of(2), 0);
|
||||
let len = len32(info.len())?;
|
||||
// The section: the layer info's length and the info, then no global layer mask.
|
||||
out.extend(len.checked_add(8).ok_or_else(too_large)?.to_be_bytes());
|
||||
out.extend(len.to_be_bytes());
|
||||
out.extend(info);
|
||||
out.extend(0u32.to_be_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn icc(o: &PsdOptions) -> Result<Option<Arc<[u8]>>, String> {
|
||||
o.embed_icc.then(|| cms::icc_bytes(&o.color_model.profile()).map_err(|e| format!("PSD colour profile: {e}"))).transpose()
|
||||
}
|
||||
|
||||
/// `img` as a flat PSD: the merged image alone, on white where it is transparent (CMYK separated
|
||||
/// from the screen colours; [`export`] draws inks).
|
||||
pub fn encode(img: &Rendered, ppi: f64, o: &PsdOptions) -> Result<Vec<u8>, String> {
|
||||
let inks = (o.color_model == ColorModel::Cmyk).then(|| jpeg::separated(img));
|
||||
let merged = planes(o.color_model, &img.to_straight(), inks.as_deref(), true, false);
|
||||
File { width: img.width, height: img.height, model: o.color_model, merged, alpha: false, records: vec![], ppi, icc: icc(o)? }.write()
|
||||
}
|
||||
|
||||
/// `region` of `doc` as a PSD with `opts` (see [`PsdOptions`]). The merged image is the export's
|
||||
/// render; with Write Layers each part is also drawn alone as its own layer, cropped to what it
|
||||
/// paints, its opacity and blend mode in the layer's record.
|
||||
pub(crate) fn export(r: &mut Renderer, doc: &Document, region: Rect, opts: &RasterExportOptions) -> Result<Vec<u8>, String> {
|
||||
let o = &opts.psd;
|
||||
let scale = opts.scale();
|
||||
let (width, height) = crate::region_pixels(region, scale);
|
||||
check_size(width, height)?;
|
||||
let ro = opts.render_options(RasterFormat::Psd);
|
||||
let (mut merged, mut alpha) = {
|
||||
let px = r.render_region_with(doc, region, scale, &ro).to_straight();
|
||||
let inks = (o.color_model == ColorModel::Cmyk).then(|| r.render_region_inks(doc, region, scale, &ro));
|
||||
let alpha = px.as_chunks::<4>().0.iter().any(|p| p[3] < 255);
|
||||
(planes(o.color_model, &px, inks.as_deref(), true, alpha), alpha)
|
||||
};
|
||||
let mut records = vec![];
|
||||
if o.layers {
|
||||
let mut base = doc.clone();
|
||||
base.layers.clear();
|
||||
let mut parts = Parts { r, base, region, scale, width, height, ro: RenderOptions { background: None, ..ro }, o, records: vec![] };
|
||||
if let Some(bg) = opts.background {
|
||||
parts.background(bg);
|
||||
}
|
||||
for l in &doc.layers {
|
||||
parts.add(l);
|
||||
}
|
||||
records = parts.records;
|
||||
}
|
||||
// Without layers an alpha plane would be read as an extra channel: the colours, on white, stay.
|
||||
if records.is_empty() && alpha {
|
||||
merged.pop();
|
||||
alpha = false;
|
||||
}
|
||||
File { width, height, model: o.color_model, merged, alpha, records, ppi: opts.ppi, icc: icc(o)? }.write()
|
||||
}
|
||||
|
||||
/// Draws the parts of a document as layer records, bottom first.
|
||||
struct Parts<'a> {
|
||||
r: &'a mut Renderer,
|
||||
/// The document without its layers: each part is drawn on it alone.
|
||||
base: Document,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Transparent: a part's pixels alone.
|
||||
ro: RenderOptions,
|
||||
o: &'a PsdOptions,
|
||||
records: Vec<Record>,
|
||||
}
|
||||
|
||||
impl Parts<'_> {
|
||||
fn colors(&self) -> usize {
|
||||
self.o.color_model.channels()
|
||||
}
|
||||
|
||||
/// The export's background colour as the bottom layer.
|
||||
fn background(&mut self, rgb: [u8; 3]) {
|
||||
let px = [rgb[0], rgb[1], rgb[2], 255];
|
||||
let inks = (self.o.color_model == ColorModel::Cmyk).then(|| jpeg::separated(&Rendered { width: 1, height: 1, pixels: px.to_vec() }));
|
||||
let values: Vec<u8> = planes(self.o.color_model, &px, inks.as_deref(), false, true).iter().map(|p| p.first().copied().unwrap_or(0)).collect();
|
||||
self.records.push(Record::solid("Background".into(), &values, self.width, self.height));
|
||||
}
|
||||
|
||||
/// Node `n` (a top-level layer, or with Maximum Editability anything in one) and its parts.
|
||||
fn add(&mut self, n: &Node) {
|
||||
// Templates and guides aren't artwork.
|
||||
if matches!(n.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }) || !(n.visible || self.o.hidden_layers) {
|
||||
return;
|
||||
}
|
||||
let (hidden, opacity) = (!n.visible, opacity_byte(n.opacity));
|
||||
if self.o.max_editability
|
||||
&& splits(n)
|
||||
&& let Some(children) = n.children()
|
||||
{
|
||||
self.records.push(Record { divider: Some(END_GROUP), ..Record::empty(GROUP_END.into(), self.colors()) });
|
||||
for c in children {
|
||||
self.add(c);
|
||||
}
|
||||
// A group in normal blending lets its layers blend with what is below it.
|
||||
let blend = if n.blend == BlendMode::Normal && !n.isolate { *b"pass" } else { blend_key(n.blend) };
|
||||
self.records.push(Record { divider: Some(OPEN_GROUP), blend, opacity, hidden, ..Record::empty(n.display_name(), self.colors()) });
|
||||
return;
|
||||
}
|
||||
let record = self.draw(n);
|
||||
self.records.push(Record { blend: blend_key(n.blend), opacity, hidden, ..record });
|
||||
}
|
||||
|
||||
/// `n` drawn alone, visible, at full opacity in normal blending (its record carries those).
|
||||
fn draw(&mut self, n: &Node) -> Record {
|
||||
let name = n.display_name();
|
||||
let mut part = n.clone();
|
||||
(part.visible, part.opacity, part.blend) = (true, 1.0, BlendMode::Normal);
|
||||
let layer = if part.is_layer() {
|
||||
part
|
||||
} else {
|
||||
let mut l = Node::layer(self.base.alloc_id(), "", LayerColor::Preset(0));
|
||||
if let Some(children) = l.children_mut() {
|
||||
children.push(Arc::new(part));
|
||||
}
|
||||
l
|
||||
};
|
||||
// What the part paints, in canvas pixels (all of it when effects may reach further than the
|
||||
// art's bounds say).
|
||||
let (w, h) = (f64::from(self.width), f64::from(self.height));
|
||||
let bounds = if reaches_beyond(&layer) { Some(Rect::new(0.0, 0.0, w, h)) } else { None };
|
||||
self.base.layers = vec![Arc::new(layer)];
|
||||
let (region, s) = (self.region, self.scale);
|
||||
let bounds = bounds.or_else(|| {
|
||||
let b = super::art_bounds(&self.base)?;
|
||||
// A pixel's margin for anti-aliasing.
|
||||
Some(Rect::new(
|
||||
((b.x0 - region.x0) * s).floor() - 1.0,
|
||||
((b.y0 - region.y0) * s).floor() - 1.0,
|
||||
((b.x1 - region.x0) * s).ceil() + 1.0,
|
||||
((b.y1 - region.y0) * s).ceil() + 1.0,
|
||||
))
|
||||
});
|
||||
let colors = self.colors();
|
||||
let Some(b) = bounds.filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return Record::empty(name, colors) };
|
||||
let (x0, y0, x1, y1) = (b.x0.clamp(0.0, w), b.y0.clamp(0.0, h), b.x1.clamp(0.0, w), b.y1.clamp(0.0, h));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Record::empty(name, colors);
|
||||
}
|
||||
let sub = Rect::new(region.x0 + x0 / s, region.y0 + y0 / s, region.x0 + x1 / s, region.y0 + y1 / s);
|
||||
let img = self.r.render_region_with(&self.base, sub, s, &self.ro);
|
||||
let inks = (self.o.color_model == ColorModel::Cmyk).then(|| self.r.render_region_inks(&self.base, sub, s, &self.ro));
|
||||
let (left, top) = (x0 as u32, y0 as u32);
|
||||
// Within the canvas, cropped to the pixels it paints.
|
||||
let (iw, ih) = (img.width.min(self.width - left) as usize, img.height.min(self.height - top) as usize);
|
||||
let px = img.to_straight();
|
||||
let painted = |x: usize, y: usize| px.get((y * img.width as usize + x) * 4 + 3).is_some_and(|a| *a > 0);
|
||||
let rows: Vec<usize> = (0..ih).filter(|y| (0..iw).any(|x| painted(x, *y))).collect();
|
||||
let (Some(&cy0), Some(&cy1)) = (rows.first(), rows.last()) else { return Record::empty(name, colors) };
|
||||
let cols: Vec<usize> = (0..iw).filter(|x| (cy0..=cy1).any(|y| painted(*x, y))).collect();
|
||||
let (Some(&cx0), Some(&cx1)) = (cols.first(), cols.last()) else { return Record::empty(name, colors) };
|
||||
let crop = |buf: &[u8]| -> Vec<u8> {
|
||||
(cy0..=cy1)
|
||||
.flat_map(|y| buf.get((y * img.width as usize + cx0) * 4..(y * img.width as usize + cx1 + 1) * 4).unwrap_or_default())
|
||||
.copied()
|
||||
.collect()
|
||||
};
|
||||
let planes = planes(self.o.color_model, &crop(&px), inks.as_deref().map(crop).as_deref(), false, true);
|
||||
Record::pixels(name, &planes, (cx1 - cx0 + 1) as u32, (cy1 - cy0 + 1) as u32, left + cx0 as u32, top + cy0 as u32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Can layer `n` be written as a group of its parts without changing its look?
|
||||
fn splits(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Layer { clip: false, .. }) && n.mask.is_none() && n.knockout != Knockout::On && !fx::has_object_fx(n)
|
||||
}
|
||||
|
||||
/// Does anything in `n` carry effects of its own that may paint outside the art's bounds?
|
||||
fn reaches_beyond(n: &Node) -> bool {
|
||||
fx::has_object_fx(n) || n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c)))
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! 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}");
|
||||
}
|
||||
Reference in New Issue
Block a user