diff --git a/Cargo.lock b/Cargo.lock index d220f9a2..3184a0be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 7a3c6d58..941faf5b 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -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, rle: Option, flip_rows: Option, + /// PSD: Write Layers. + layers: Option, + max_editability: Option, + hidden_layers: Option, } 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 { 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 { "tiff" => RasterFormat::Tiff, "bmp" => RasterFormat::Bmp, "tga" => RasterFormat::Tga, + "psd" => RasterFormat::Psd, _ => RasterFormat::WebP, }; check_options(format, &settings)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index ea76cadc..d73629cc 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "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 { "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 .html / .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 .html / .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; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 4eab7048..c04120d1 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -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())); diff --git a/crates/engine/src/cmd/fileio/tests_psd.rs b/crates/engine/src/cmd/fileio/tests_psd.rs new file mode 100644 index 00000000..31fc3015 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_psd.rs @@ -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 { + 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) { + 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::>()).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 1", "", "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}"); +} diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 13916831..1e366b67 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -75,7 +75,8 @@ fn export_tool_lists_engine_formats_and_options() { "eps", "emf", "wmf", - "tga" + "tga", + "psd" ]) ); for k in ["artboard", "range", "options"] { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 5d9995b3..f974efe5 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec { 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::>(), "description": "Default: from the path's extension"}, diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index a0ae310e..44c14f57 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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 } diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index eb88a5f6..fd8338bb 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -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, 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, 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; diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs new file mode 100644 index 00000000..304e53a5 --- /dev/null +++ b/crates/render/src/encode/psd.rs @@ -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 = ""; + +/// 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> { + 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> = 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, data: &mut Vec) { + 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)>, + blend: [u8; 4], + opacity: u8, + hidden: bool, + /// Section divider: [`OPEN_GROUP`] or [`END_GROUP`]. + divider: Option, +} + +/// 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], 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) -> 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 = 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 = 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::try_from(n).map_err(|_| too_large()) +} + +/// A tagged block of additional layer information, padded to an even length. +fn block(out: &mut Vec, 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, 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>, + alpha: bool, + /// Bottom first. + records: Vec, + ppi: f64, + icc: Option>, +} + +/// 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, 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) -> 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>, 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, 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, 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, +} + +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 = 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 = (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 = (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 { + (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))) +} diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs new file mode 100644 index 00000000..60f5aae0 --- /dev/null +++ b/crates/render/src/encode/tests_psd.rs @@ -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 { + 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, +} + +/// 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, + /// 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::>()).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> { + 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) { + 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 { + ::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 = 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)> = 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, + [ + ("", Some(3)), + (rect.as_str(), None), + ("Layer 1", Some(1)), + ("", Some(3)), + (rect.as_str(), None), + ("", 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::>(); + 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::>(), [255, 255, 255, 0], "black ink"); + assert_eq!(planes.iter().map(|p| p[9]).collect::>(), [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}"); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 539d8724..e7b83d78 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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; diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 221885d9..ce53fed6 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -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 { 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 { ("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), } } diff --git a/crates/ui-egui/src/dialogs/psd_options.rs b/crates/ui-egui/src/dialogs/psd_options.rs new file mode 100644 index 00000000..17c2f4c9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/psd_options.rs @@ -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) { + 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(); +} diff --git a/crates/ui-egui/src/dialogs/tests_psd.rs b/crates/ui-egui/src/dialogs/tests_psd.rs new file mode 100644 index 00000000..a59cf589 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_psd.rs @@ -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"); +} diff --git a/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs index 3253bf8d..24c6efa5 100644 --- a/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs +++ b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs @@ -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, } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ba3f810d..8c0e8c32 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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`. diff --git a/docs/mcp.md b/docs/mcp.md index e5c0d032..b06277f3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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"}}} +```