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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 16:12:03 +07:00
1 parent ed4eceb837
commit c06c13f577
18 files changed
+1182 -19

No files matched your search

Generated
+10
View File
@@ -2996,6 +2996,15 @@ dependencies = [
"unarray",
]
[[package]]
name = "psd"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -4419,6 +4428,7 @@ dependencies = [
"jpeg-encoder",
"log",
"proptest",
"psd",
"serde_json",
"tiff",
"vectorcraft-brush",
+21 -4
View File
@@ -12,7 +12,7 @@ use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions};
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, tga};
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, psd, tga};
use super::super::*;
use super::Format;
@@ -171,6 +171,10 @@ struct RasterOptions {
file_format: Option<String>,
rle: Option<bool>,
flip_rows: Option<bool>,
/// PSD: Write Layers.
layers: Option<bool>,
max_editability: Option<bool>,
hidden_layers: Option<bool>,
}
impl RasterOptions {
@@ -228,6 +232,13 @@ impl RasterOptions {
gray: color_model == jpeg::ColorModel::Gray,
},
tga: tga::TgaOptions { depth: self.depth.unwrap_or(24) },
psd: psd::PsdOptions {
color_model,
layers: self.layers.unwrap_or(true),
max_editability: self.max_editability.unwrap_or(false),
hidden_layers: self.hidden_layers.unwrap_or(false),
embed_icc,
},
})
}
}
@@ -321,10 +332,15 @@ const WEBP_SIDE: f64 = 16383.0;
/// Refuse a raster export its format can't store (instead of writing an empty file). The
/// renderer's own size limits are [`vectorcraft_render::raster_size`]'s.
fn check_format_size(f: &Format, w: f64, h: f64) -> Result<()> {
if f.id == "webp" && (w.round() > WEBP_SIDE || h.round() > WEBP_SIDE) {
let max = match f.id {
"webp" => WEBP_SIDE,
"psd" => f64::from(psd::MAX_SIDE),
_ => return Ok(()),
};
if w.round() > max || h.round() > max {
return Err(bad(
C,
format!("{} × {} pixels is too large for {} (at most {WEBP_SIDE} pixels a side): lower the scale", w.round(), h.round(), f.label),
format!("{} × {} pixels is too large for {} (at most {max} pixels a side): lower the scale", w.round(), h.round(), f.label),
));
}
Ok(())
@@ -379,7 +395,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
}
"png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" => {
"png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" | "psd" => {
let o: RasterOptions = options(f, p)?;
// New Document → Background Contents: White makes the export opaque, unless `background`
// says otherwise (JPEG has no alpha: white either way).
@@ -394,6 +410,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
"tiff" => RasterFormat::Tiff,
"bmp" => RasterFormat::Bmp,
"tga" => RasterFormat::Tga,
"psd" => RasterFormat::Psd,
_ => RasterFormat::WebP,
};
check_options(format, &settings)?;
+39 -2
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc,
export::serialize
),
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, bmp: colorModel?: rgb|gray, depth?: 24 (1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), tga: depth?: 24 (16 one-bit alpha, 24, 32 with alpha); formats without alpha are flattened on white} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, bmp: colorModel?: rgb|gray, depth?: 24 (1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), tga: depth?: 24 (16 one-bit alpha, 24, 32 with alpha), psd: colorModel?: rgb|cmyk|gray, layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true, at most 30000 pixels a side; formats without alpha are flattened on white} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -396,6 +396,39 @@ const TGA_DEPTH: FormatOption = FormatOption {
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
};
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const PSD_LAYERS: FormatOption = FormatOption {
name: "layers",
ty: "boolean",
default: "true",
description: "write layers: each top-level layer a pixel layer with its opacity and blend mode (a background colour is a Background layer); false: one flat image, on white where transparent",
};
const MAX_EDITABILITY: FormatOption = FormatOption {
name: "maxEditability",
ty: "boolean",
default: "false",
description: "with layers: layers and sublayers become groups and every object a layer of its own, named as the Layers panel names it (text by its text); clipping, masked or knockout layers stay one layer",
};
const PSD_HIDDEN_LAYERS: FormatOption = FormatOption {
name: "hiddenLayers",
ty: "boolean",
default: "false",
description: "with layers: hidden layers (and hidden objects) are written as hidden layers instead of left out",
};
const PSD_OPTIONS: &[FormatOption] = &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
PPI,
SCALE,
BACKGROUND,
ANTI_ALIAS,
COLOR_MODEL,
PSD_LAYERS,
MAX_EDITABILITY,
PSD_HIDDEN_LAYERS,
EMBED_ICC,
];
/// A format `document.open` reads but nothing writes yet.
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
@@ -521,6 +554,7 @@ pub const FORMATS: &[Format] = &[
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: false, write: true, raster: true, options: PSD_OPTIONS },
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -861,3 +895,6 @@ mod tests_metafile;
#[cfg(test)]
mod tests_tiffbmp;
#[cfg(test)]
mod tests_psd;
+2 -1
View File
@@ -174,7 +174,8 @@ fn formats_query_lists_readers_writers_and_options() {
"eps",
"emf",
"wmf",
"tga"
"tga",
"psd"
]
);
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
+95
View File
@@ -0,0 +1,95 @@
//! PSD export through `document.export`: the options reach the file (layers, groups, names, colour
//! model), impossible ones are refused, and `document.formats` lists the format.
use serde_json::{Value, json};
use super::*;
/// A 60 × 40 pt document: a rectangle on Layer 1, and the text "Hello" on "Words".
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap();
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
s.execute("layer.new", &json!({"name": "Words"})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 30, "text": "Hello"})).unwrap();
s
}
fn export(s: &mut Session, p: Value) -> Vec<u8> {
let r = s.execute("document.export", &merge(json!({"format": "psd"}), p)).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// The colour mode, the layer count and the layers' names, bottom first.
fn layers(f: &[u8]) -> (u16, i16, Vec<String>) {
assert_eq!(&f[..4], b"8BPS");
let be16 = |at: usize| u16::from_be_bytes([f[at], f[at + 1]]);
let be32 = |at: usize| u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) as usize;
let mut at = 26;
at += 4 + be32(at);
at += 4 + be32(at);
let count = if be32(at) == 0 { 0 } else { be16(at + 8) as i16 };
// Every record's Unicode name block.
let names = f
.windows(8)
.enumerate()
.filter(|(_, w)| w == b"8BIMluni")
.map(|(i, _)| {
let n = be32(i + 12);
String::from_utf16(&(0..n).map(|k| be16(i + 16 + k * 2)).collect::<Vec<_>>()).unwrap()
})
.collect();
(be16(24), count, names)
}
#[test]
fn psd_writes_layers_groups_and_text_named_after_it() {
let mut s = session();
let (mode, count, names) = layers(&export(&mut s, json!({})));
assert_eq!((mode, count, names), (3, -2, vec!["Layer 1".to_string(), "Words".to_string()]), "a pixel layer per top-level layer");
let (_, count, names) = layers(&export(&mut s, json!({"maxEditability": true})));
assert_eq!(count, -6);
assert_eq!(names, ["</Layer group>", "<Rectangle>", "Layer 1", "</Layer group>", "Hello", "Words"], "groups, and text named after its text");
let (mode, count, names) = layers(&export(&mut s, json!({"layers": false, "colorModel": "cmyk"})));
assert_eq!((mode, count, names.len()), (4, 0, 0), "flat CMYK");
assert_eq!(layers(&export(&mut s, json!({"colorModel": "gray"}))).0, 1);
// A white background is a Background layer under the others.
let (_, count, names) = layers(&export(&mut s, json!({"background": "white"})));
assert_eq!((count, names[0].as_str()), (3, "Background"));
// Hidden layers: left out, unless asked for.
let words = s.doc().unwrap().doc.layers[1].id;
s.execute("layer.setProps", &json!({"id": words.0, "visible": false})).unwrap();
assert_eq!(layers(&export(&mut s, json!({}))).2, ["Layer 1"]);
assert_eq!(layers(&export(&mut s, json!({"hiddenLayers": true}))).2, ["Layer 1", "Words"]);
assert!(s.execute("document.export", &json!({"format": "psd", "colorModel": "lab"})).is_err());
}
#[test]
fn psd_is_listed_with_its_options_and_writes_one_file_per_artboard() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 30, "height": 20, "artboards": 2})).unwrap();
let r = s.execute("document.formats", &json!({})).unwrap();
let psd = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "psd").unwrap().clone();
assert_eq!(
(psd["label"].as_str(), psd["write"].as_bool(), psd["read"].as_bool(), psd["raster"].as_bool()),
(Some("PSD"), Some(true), Some(false), Some(true))
);
for k in ["colorModel", "layers", "maxEditability", "hiddenLayers", "embedIcc", "ppi", "antiAlias", "background"] {
assert!(psd["options"].get(k).is_some(), "psd takes {k}");
}
let r = s.execute("document.export", &json!({"format": "psd", "useArtboards": true})).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(files, ["Untitled-1-Artboard-1.psd", "Untitled-1-Artboard-2.psd"]);
// One artboard, as bytes.
let r = s.execute("document.serialize", &json!({"format": "psd", "artboard": 1})).unwrap();
assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()[..4], b"8BPS");
// 32000 pixels a side: within raster exports' limits, beyond the format's.
s.execute("file.new", &json!({"width": 500, "height": 10})).unwrap();
let err = s.execute("document.export", &json!({"format": "psd", "scale": 64})).unwrap_err().to_string();
assert!(err.contains("too large for PSD"), "{err}");
}
+2 -1
View File
@@ -75,7 +75,8 @@ fn export_tool_lists_engine_formats_and_options() {
"eps",
"emf",
"wmf",
"tga"
"tga",
"psd"
])
);
for k in ["artboard", "range", "options"] {
+1 -1
View File
@@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff/bmp/tga (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff/bmp/tga/psd (one rendered artboard; png8 is an indexed .png; psd keeps the layers), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
obj(
json!({
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
+2
View File
@@ -34,5 +34,7 @@ vectorcraft-testkit = { workspace = true }
# Decoders the export tests read their files back with.
image = { workspace = true, features = ["tga"] }
tiff = "0.11"
# Reads the PSD exports back (MIT OR Apache-2.0).
psd = "0.3"
proptest = { workspace = true }
serde_json = { workspace = true }
+19 -6
View File
@@ -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;
+529
View File
@@ -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)))
}
+323
View File
@@ -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}");
}
+5
View File
@@ -41,6 +41,7 @@ pub mod pdf_presets;
pub mod place;
pub mod placement_options;
mod png_options;
mod psd_options;
pub mod raster_effects;
pub mod recolor;
mod recovery;
@@ -234,6 +235,7 @@ registry! {
DxfImport: [dxf_import::KIND] => dxf_import::SPEC,
RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC,
SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC,
PsdOptions: ["psdOptions"] => png_options::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@@ -362,3 +364,6 @@ mod tests_tiff_bmp_tga;
#[cfg(test)]
mod tests_save_for_web;
#[cfg(test)]
mod tests_psd;
+6 -3
View File
@@ -1,7 +1,8 @@
//! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced),
//! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image
//! map), WebP Options, and PNG-8 and GIF Options (the palette: colour reduction, colours, dither,
//! transparency and matte), and TIFF, BMP and Targa Options ([`super::tiff_bmp_tga`]). Fields
//! transparency and matte), TIFF, BMP and Targa Options ([`super::tiff_bmp_tga`]) and PSD Options
//! ([`super::psd_options`]). Fields
//! are `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the
//! dialog's.
@@ -12,7 +13,7 @@ use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, ColorModel, Method};
use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
use super::{DialogSpec, form, tiff_bmp_tga};
use super::{DialogSpec, form, psd_options, tiff_bmp_tga};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
@@ -69,7 +70,7 @@ pub(super) fn defaults(app: &VectorcraftApp, f: &Format) -> Map<String, Value> {
let mut o = Map::new();
o.insert("background".into(), json!(if white { "white" } else { "transparent" }));
o.insert("antiAlias".into(), json!(AntiAlias::default().id()));
// A CMYK document exports CMYK JPEGs and TIFFs by default.
// A CMYK document exports CMYK JPEGs, TIFFs and PSDs by default.
let cmyk = app.session.active().is_some_and(|st| st.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk);
match f.id {
"png" => {
@@ -99,6 +100,7 @@ pub(super) fn defaults(app: &VectorcraftApp, f: &Format) -> Map<String, Value> {
("interlaced".into(), json!(false)),
]);
}
"psd" => psd_options::defaults(cmyk, &mut o),
id => tiff_bmp_tga::defaults(id, cmyk, &mut o),
}
o
@@ -205,6 +207,7 @@ pub(super) fn option_rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, screens:
label(ui, "Lossless (lossy WebP isn't available yet)");
ui.end_row();
}
"psd" => psd_options::rows(ui, d, &label),
id => tiff_bmp_tga::rows(ui, d, id, &label),
}
}
+52
View File
@@ -0,0 +1,52 @@
//! The options of PSD (colour model, a flat image or layers, Maximum Editability, hidden layers,
//! profile), after the raster export rows it shares with PNG Options ([`super::png_options`]): the
//! `psdOptions` dialog.
use serde_json::{Map, Value, json};
use vectorcraft_render::encode::jpeg::ColorModel;
use vectorcraft_render::encode::psd::PsdOptions;
use super::form;
use super::png_options::choice;
use crate::state::Dialog;
/// The options PSD starts with (`cmyk`: a CMYK document exports CMYK).
pub(super) fn defaults(cmyk: bool, o: &mut Map<String, Value>) {
let p = PsdOptions::default();
let model = if cmyk { ColorModel::Cmyk } else { p.color_model };
o.extend([
("colorModel".into(), json!(model.id())),
("layers".into(), json!(p.layers)),
("maxEditability".into(), json!(p.max_editability)),
("hiddenLayers".into(), json!(p.hidden_layers)),
("embedIcc".into(), json!(p.embed_icc)),
]);
}
/// The grid rows of PSD's own options.
pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Color Model:");
choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label));
ui.end_row();
label(ui, "Options:");
ui.vertical(|ui| {
let mut layers = d.bool("layers");
if ui.radio_value(&mut layers, false, "Flat Image").changed() | ui.radio_value(&mut layers, true, "Write Layers").changed() {
d.fields.insert("layers".into(), json!(layers));
}
ui.add_enabled_ui(layers, |ui| {
for (key, text) in [("maxEditability", "Maximum Editability"), ("hiddenLayers", "Include Hidden Layers")] {
ui.horizontal(|ui| {
ui.add_space(22.0);
form::check(ui, d, key, text);
});
}
});
});
ui.end_row();
ui.label("");
form::check(ui, d, "embedIcc", "Embed ICC Profile");
ui.end_row();
}
+49
View File
@@ -0,0 +1,49 @@
//! PSD Options through Export As, drawn headlessly.
use serde_json::json;
use super::tests_export::{app, frame, kind, set};
use super::*;
#[test]
fn psd_options_pick_flat_or_layers_and_the_colour_model() {
let (mut app, written) = app(1);
app.run("file.exportAs", json!({"format": "psd"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("psdOptions"));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((spec(&d.kind).heading)(d), "PSD Options");
assert_eq!(
(d.str("colorModel"), d.bool("layers"), d.bool("maxEditability"), d.bool("hiddenLayers"), d.bool("embedIcc")),
("rgb".into(), true, false, false, true)
);
assert!(tiff_bmp_tga::keeps_alpha("psd", d), "layers keep transparency");
frame(&mut app);
set(&mut app, "maxEditability", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
let (path, psd) = written.borrow()[0].clone();
assert_eq!(path, "/out/Untitled-1.psd");
assert_eq!(&psd[..4], b"8BPS");
// A group with the ellipse: three records, the merged image's alpha their transparency.
let at = 26 + 4 + 4 + u32::from_be_bytes(psd[30..34].try_into().unwrap()) as usize;
assert_eq!(i16::from_be_bytes([psd[at + 8], psd[at + 9]]), -3);
assert!(app.ui.dialog.is_none());
// Flat: flattened on white, so the background list starts at White.
app.run("file.exportAs", json!({"format": "psd"})).unwrap();
confirm(&mut app).unwrap();
set(&mut app, "layers", json!(false));
set(&mut app, "colorModel", json!("gray"));
assert!(!tiff_bmp_tga::keeps_alpha("psd", app.ui.dialog.as_ref().unwrap()));
frame(&mut app);
confirm(&mut app).unwrap();
let psd = written.borrow()[1].1.clone();
assert_eq!((u16::from_be_bytes([psd[12], psd[13]]), u16::from_be_bytes([psd[24], psd[25]])), (1, 1), "one grey channel");
// A CMYK document exports CMYK by default.
app.run("object.convertDocumentColorMode", json!({"mode": "cmyk"})).unwrap();
app.run("file.exportAs", json!({"format": "psd"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("colorModel"), "cmyk");
}
@@ -64,6 +64,8 @@ pub(super) fn keeps_alpha(id: &str, d: &Dialog) -> bool {
"tiff" => ColorModel::from_id(&d.str("colorModel")).unwrap_or_default() == ColorModel::Rgb,
"bmp" => d.f64("depth", 24.0) == 32.0,
"tga" => d.f64("depth", 24.0) != 24.0,
// A flat PSD is flattened; its layers keep transparency.
"psd" => d.bool("layers"),
_ => true,
}
}
+6
View File
@@ -386,3 +386,9 @@ a preset) plus `__view` (`original`, `optimized`, `2up`, `4up`), `__zoom` (`fit`
the image, or the page with HTML output; the web downloads the files); with `discard: true` it is Done: it remembers
the settings and closes. `file.saveForWeb` with settings saves without the dialog, and `file.saveForWeb.browser`
writes the HTML page to a temporary folder and opens it in the default browser (desktop).
PSD Options: Export As… with the PSD format (`file.exportAs {format: "psd"}`, then the save dialog) opens `psdOptions`:
the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK
document starts on `cmyk`), `layers` (true: Write Layers; false: Flat Image, whose background list starts at White),
`maxEditability` and `hiddenLayers` (with layers) and `embedIcc`. `ui.dialog.set` the fields and `ui.dialog.confirm`
writes the file(s) with `document.export`.
+19 -1
View File
@@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). |
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. |
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
@@ -1454,3 +1454,21 @@ writes the `.css` file with the PNGs of rasterized art next to it (no path: `dat
{"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}}
{"name":"run_command","arguments":{"command":"css.export","params":{"path":"/tmp/site/styles.css","scope":"all","rasterize":true}}}
```
## PSD export
`document.export` (and `export`) writes layered bitmaps (`.psd`, 8 bits per channel) like the other raster formats:
one artboard (or `useArtboards`, one file each), at `ppi`, with `antiAlias`, in `colorModel` `rgb`, `cmyk` (ink
amounts in the working CMYK space) or `gray`, with `embedIcc`. With `layers` (default true) each top-level layer is a
pixel layer of its own, drawn alone and cropped to what it paints, with its name, opacity and blend mode; a
`background` colour becomes a `Background` layer under them, and the merged image (what readers that skip layers
show) is the flat render. `maxEditability: true` turns layers and sublayers into groups and every object into a layer
named as the Layers panel names it (text objects by their text); layers with a clipping mask, an opacity mask, an
appearance of their own or knockout stay one pixel layer. Hidden layers and objects are left out unless
`hiddenLayers: true` writes them as hidden layers. `layers: false` writes one flat image, on white where nothing is
drawn. Files are at most 30000 pixels a side; PSD files don't open in VectorCraft.
```json
{"name":"export","arguments":{"path":"/tmp/poster.psd","options":{"ppi":300,"maxEditability":true}}}
{"name":"run_command","arguments":{"command":"document.export","params":{"format":"psd","layers":false,"colorModel":"cmyk"}}}
```