mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04: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 <[email protected]>
This commit is contained in:
1 parent
ed4eceb837
commit
c06c13f577
18 files changed
+1182
-19
No files matched your search
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
@@ -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()));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -75,7 +75,8 @@ fn export_tool_lists_engine_formats_and_options() {
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"tga"
|
||||
"tga",
|
||||
"psd"
|
||||
])
|
||||
);
|
||||
for k in ["artboard", "range", "options"] {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 }
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"}}}
|
||||
```
|
||||
Reference in new issue
Block a user