diff --git a/Cargo.lock b/Cargo.lock index faa215496b..cec58ffb71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5247,6 +5247,7 @@ dependencies = [ "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-psd", "photocraft-raster", "photocraft-raw", "serde", diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index db256c2726..4542a71da9 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -45,6 +45,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [dev-dependencies] serde = { workspace = true } tempfile = "3" +# Reading back a saved PSD's merged-image flag (#8064). +photocraft-psd = { workspace = true } # Synthetic camera raws (#7789). photocraft-raw = { workspace = true, features = ["testgen"] } # Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in diff --git a/apps/photocraft-cli/src/args.rs b/apps/photocraft-cli/src/args.rs index 3f29309ad5..87c0b5599c 100644 --- a/apps/photocraft-cli/src/args.rs +++ b/apps/photocraft-cli/src/args.rs @@ -87,6 +87,12 @@ pub(crate) struct ExportArgs { /// composite/full.png; it may be omitted. Check the manifest's full_composite member. #[arg(long)] pub(crate) pcraft_full_composite: bool, + /// A layered PSD/PSB stores Photoshop's white placeholder instead of the merged image + /// (Maximize Compatibility off): a smaller file and a faster save, but readers that show only + /// the merged image see white. Bitmap, Indexed Color and Duotone documents still write the + /// real image + #[arg(long)] + pub(crate) no_maximize_compatibility: bool, } /// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …) diff --git a/apps/photocraft-cli/src/output.rs b/apps/photocraft-cli/src/output.rs index bedab3dc78..05861d7477 100644 --- a/apps/photocraft-cli/src/output.rs +++ b/apps/photocraft-cli/src/output.rs @@ -33,6 +33,7 @@ impl From<&ExportArgs> for ExportOptions { o.encode.dds.compression = c; } o.encode.dds.mipmaps = a.dds_mipmaps; + o.psd_maximize_compatibility = !a.no_maximize_compatibility; o } } diff --git a/apps/photocraft-cli/tests/it/cli.rs b/apps/photocraft-cli/tests/it/cli.rs index d7cb7b299a..a0c72c39b2 100644 --- a/apps/photocraft-cli/tests/it/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -867,6 +867,24 @@ fn dds_converts_both_ways_with_the_compression_asked_for() { } } +/// #8064: `--no-maximize-compatibility` writes a layered PSD/PSB with Photoshop's placeholder +/// instead of the merged image, flagged in resource 1057; without it, the real image. +#[test] +fn no_maximize_compatibility_writes_the_psd_placeholder() { + let d = tmp("maximize-compat"); + let a = d.join("a.png"); + write_png(&a, 32, 16, 3); + for (args, ext, real) in + [(&[][..], "psd", true), (&["--no-maximize-compatibility"][..], "psd", false), (&["--no-maximize-compatibility"][..], "psb", false)] + { + let out = d.join(format!("a.{ext}")); + ok(bin().arg("run").arg(&a).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&out).args(args)); + let psd = photocraft_psd::PsdFile::from_bytes(&std::fs::read(&out).unwrap()).unwrap(); + assert_eq!(psd.has_real_merged_data(), Some(real), "{args:?} {ext}"); + } + std::fs::remove_dir_all(d).unwrap(); +} + /// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is a /// usage error and nothing is written. #[test] diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 0f7540d139..2f6b7ea818 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -184,6 +184,9 @@ impl Headless { let tiff_bool = |key| photocraft_engine::file_cmds::tiff_bool_param(&p, key, "doc.save").map_err(|e| bad(e.to_string())); opts.tiff_transparency = tiff_bool("tiffTransparency")?; opts.encode.tiff_pyramid = tiff_bool("tiffPyramid")?.unwrap_or(false); + if let Some(maximize) = photocraft_engine::file_cmds::maximize_compatibility_param(&p, "doc.save").map_err(|e| bad(e.to_string()))? { + opts.psd_maximize_compatibility = maximize; + } match p.get("quality") { None | Some(Value::Null) => {} Some(v) => { diff --git a/crates/automation/src/rpc/tests.rs b/crates/automation/src/rpc/tests.rs index cecc69f376..2e9348ae38 100644 --- a/crates/automation/src/rpc/tests.rs +++ b/crates/automation/src/rpc/tests.rs @@ -303,6 +303,28 @@ fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() { std::fs::remove_dir_all(dir).unwrap(); } +/// #8064: `doc.save` takes `maximizeCompatibility` for a layered PSD's merged image (false: the +/// placeholder, flagged in resource 1057); anything but a boolean writes nothing. +#[test] +fn doc_save_takes_maximize_compatibility() { + let dir = std::env::temp_dir().join(format!("pc-rpc-save-maximize-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = Headless::trusted_local(); + h.handle("doc.new", json!({"width": 64, "height": 32})).unwrap(); + h.handle("engine.execute", json!({"command": "layer.new.layer"})).unwrap(); + let real_merged = |p: &std::path::Path| photocraft_psd::PsdFile::from_bytes(&std::fs::read(p).unwrap()).unwrap().has_real_merged_data(); + for (name, maximize, real) in [("default.psd", json!(null), true), ("off.psd", json!(false), false), ("on.psd", json!(true), true)] { + let path = dir.join(name); + h.handle("doc.save", json!({"path": path.to_string_lossy(), "maximizeCompatibility": maximize})).unwrap(); + assert_eq!(real_merged(&path), Some(real), "{name}"); + } + let bad = dir.join("bad.psd"); + let err = h.handle("doc.save", json!({"path": bad.to_string_lossy(), "maximizeCompatibility": 0})).unwrap_err().to_string(); + assert!(err.contains("maximizeCompatibility"), "{err}"); + assert!(!bad.exists(), "a bad value writes nothing"); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() { let mut h = Headless::new(); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 46468e0352..2e34cf6cbe 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -113,6 +113,11 @@ pub struct ExportLayersParams { /// Encoder quality, 0..12, for the formats that take one. #[serde(default)] pub quality: Option, + /// PSD/PSB: write the real merged image (Photoshop's Maximize Compatibility); false writes + /// Photoshop's white placeholder (Bitmap, Indexed Color and Duotone documents still write the + /// real image). Unset: the engine's Preferences › File Handling decides. + #[serde(default, rename = "maximizeCompatibility")] + pub maximize_compatibility: Option, } #[derive(Debug, Default, Deserialize, JsonSchema)] @@ -214,6 +219,13 @@ pub struct SaveParams { /// The save may omit it; check the manifest's `full_composite` member. Off by default. #[serde(default, rename = "pcraftFullComposite")] pub pcraft_full_composite: Option, + /// PSD/PSB: write the real merged image (Photoshop's Maximize Compatibility). false writes + /// Photoshop's white placeholder instead: a smaller file and a faster save, but readers that + /// show only the merged image see white. Bitmap, Indexed Color and Duotone documents still + /// write the real image. Unset: the real image (in bridge mode, the app's + /// Preferences › File Handling decides). + #[serde(default, rename = "maximizeCompatibility")] + pub maximize_compatibility: Option, #[serde(default)] pub index: Option, } @@ -798,6 +810,9 @@ impl PhotocraftMcp { if let Some(v) = p.quality { opts.insert("quality".into(), json!(v)); } + if let Some(v) = p.maximize_compatibility { + opts.insert("maximizeCompatibility".into(), json!(v)); + } let opts = Value::Object(opts); // Bridge mode runs the app on this machine, which writes its own paths like any command. let mut bridge_params = opts.clone(); @@ -1186,9 +1201,10 @@ impl PhotocraftMcp { if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless - // does), the Targa bits, the DDS options and the TIFF compression, transparency and - // pyramid, so the other options are headless-only. Saying so beats saving with - // defaults while the caller believes their quality or format was applied. + // does), the Targa bits, the DDS options, the TIFF compression, transparency and + // pyramid, and Maximize Compatibility, so the other options are headless-only. Saying + // so beats saving with defaults while the caller believes their quality or format was + // applied. let unsupported: Vec<&str> = [ p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), @@ -1226,6 +1242,9 @@ impl PhotocraftMcp { if p.tiff_pyramid { params["tiffPyramid"] = json!(true); } + if let Some(maximize) = p.maximize_compatibility { + params["maximizeCompatibility"] = json!(maximize); + } return to_result(b.call("app.save", params).await); } let Some(r) = self @@ -1243,6 +1262,9 @@ impl PhotocraftMcp { } opts.encode.tiff_pyramid = p.tiff_pyramid; opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps }; + if let Some(maximize) = p.maximize_compatibility { + opts.psd_maximize_compatibility = maximize; + } if let Some(q) = p.quality { opts.encode.jpeg_quality = q; opts.encode.webp_quality = q; diff --git a/crates/automation/tests/it/mcp.rs b/crates/automation/tests/it/mcp.rs index b2746303b8..7b4ce8aae3 100644 --- a/crates/automation/tests/it/mcp.rs +++ b/crates/automation/tests/it/mcp.rs @@ -513,6 +513,70 @@ async fn headless_doc_save_writes_tiff_options() { cleanup(&dir); } +/// #8064: headless `doc_save`/`doc_export` take `maximizeCompatibility` for a layered PSD/PSB's +/// merged image: false writes Photoshop's placeholder and says so in resource 1057; unset, the +/// real image. A value that isn't a boolean is refused before anything is written. +#[tokio::test(flavor = "multi_thread")] +async fn headless_doc_save_takes_maximize_compatibility() { + let dir = tmp("maximize-compat"); + let client = connect(headless_in(&dir)).await; + call(&client, "doc_new", json!({"width": 64, "height": 32, "background": "white"})).await; + let r = call(&client, "command_run", json!({"id": "layer.new.layer", "params": {}})).await; + assert_ne!(r.is_error, Some(true), "{}", text(&r)); + for (tool, params, real) in [ + ("doc_export", json!({"path": "default.psd"}), true), + ("doc_export", json!({"path": "off.psd", "maximizeCompatibility": false}), false), + ("doc_save", json!({"path": "off.psb", "maximizeCompatibility": false}), false), + ("doc_save", json!({"path": "on.psd", "maximizeCompatibility": true}), true), + ] { + let r = call(&client, tool, params.clone()).await; + assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r)); + let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap(); + assert_eq!(photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}"); + } + let r = call(&client, "doc_export", json!({"path": "bad.psd", "maximizeCompatibility": "no"})).await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + assert!(!dir.join("bad.psd").exists()); + client.cancel().await.unwrap(); + cleanup(&dir); +} + +/// #8064: `doc_export_layers` takes `maximizeCompatibility` too, for PSD/PSB layer files. +#[tokio::test(flavor = "multi_thread")] +async fn headless_doc_export_layers_takes_maximize_compatibility() { + let dir = tmp("export-layers-maximize-compat"); + let client = connect(headless_in(&dir)).await; + call(&client, "doc_new", json!({"width": 16, "height": 16, "background": "white", "name": "card"})).await; + for (sub, params, real) in + [("on", json!({"dir": "on", "format": "psd"}), true), ("off", json!({"dir": "off", "format": "psd", "maximizeCompatibility": false}), false)] + { + let result = json_of(&call(&client, "doc_export_layers", params.clone()).await); + assert_eq!(result["count"], 1, "{params}: {result}"); + let file = result["files"][0].as_str().unwrap(); + assert!(file.starts_with(sub), "{file}"); + let bytes = std::fs::read(dir.join(file)).unwrap(); + assert_eq!(photocraft_psd::PsdFile::from_bytes(&bytes).unwrap().has_real_merged_data(), Some(real), "{params}"); + } + let r = call(&client, "doc_export_layers", json!({"dir": "bad", "format": "psd", "maximizeCompatibility": "no"})).await; + assert_eq!(r.is_error, Some(true), "{}", text(&r)); + client.cancel().await.unwrap(); + cleanup(&dir); +} + +/// #8064: `maximizeCompatibility` reaches the app's `app.save` through the bridge; unset, nothing +/// is sent and the app's preference decides. +#[tokio::test(flavor = "multi_thread")] +async fn bridge_doc_save_forwards_maximize_compatibility() { + let (addr, app) = fake_app().await; + let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await; + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.psd", "maximizeCompatibility": false})).await); + assert_eq!(saved, json!({"saved": {"path": "out.psd", "maximizeCompatibility": false}})); + let saved = json_of(&call(&client, "doc_save", json!({"path": "out.psd"})).await); + assert_eq!(saved, json!({"saved": {"path": "out.psd"}})); + client.cancel().await.unwrap(); + app.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn bridge_response_budget_drops_connection_without_retrying_the_operation() { use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES}; diff --git a/crates/engine/src/cmd/file_cmds.rs b/crates/engine/src/cmd/file_cmds.rs index ccba775115..495f9d1892 100644 --- a/crates/engine/src/cmd/file_cmds.rs +++ b/crates/engine/src/cmd/file_cmds.rs @@ -63,8 +63,8 @@ use processing::{conditional_mode_change, fit_image, flatten_all_effects, flatte use save::save_a_copy; pub(crate) use save::{SaveOpts, encode, save_doc}; pub use save::{ - dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, pcraft_full_composite_param, tga_bits_param, tiff_bool_param, tiff_compression_param, - tiff_would_write_layers, + dds_param, dpx_bits_param, jpeg_format_param, jpeg_quality, maximize_compatibility_param, pcraft_full_composite_param, tga_bits_param, tiff_bool_param, + tiff_compression_param, tiff_would_write_layers, }; mod batch_naming; @@ -272,7 +272,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tiffTransparency":bool? (TIFF: Save Transparency; off composites over white; unset, kept unless the document has alpha or spot channels),"tiffPyramid":bool=false (TIFF: Save Image Pyramid, half-size copies after the image),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"pcraftFullComposite":bool? (.pcraft: request the flattened document at its canvas size as composite/full.png; it may be omitted; check the manifest's full_composite member; unset, Preferences › File Handling)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"jpegFormat":"baseline|optimized|progressive"? (JPEG; default baseline),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tiffTransparency":bool? (TIFF: Save Transparency; off composites over white; unset, kept unless the document has alpha or spot channels),"tiffPyramid":bool=false (TIFF: Save Image Pyramid, half-size copies after the image),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"maximizeCompatibility":bool? (PSD/PSB: write the real merged image; unset, Preferences > File Handling > Maximize PSD and PSB File Compatibility),"pcraftFullComposite":bool? (.pcraft: request the flattened document at its canvas size as composite/full.png; it may be omitted; check the manifest's full_composite member; unset, Preferences › File Handling)}"##, native_doc, save_a_copy ), @@ -349,7 +349,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"action":name|index|[steps]? (used only when steps is absent; named actions exclude disabled steps),"set":str? (for a named action),"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"source":"folder|open"="folder" (open runs on live documents),"input":folder|[paths] (folder source),"errorLog":path? (blank disables it),"suppressOpenOptions":bool=false (use caller RAW defaults and fully supported prior XMP without a dialog; unsupported prior settings or unanswerable profile questions fail the input),"suppressWarnings":bool=false (apply caller opening policy without profile questions; import diagnostics remain in notices),"overrideOpen":bool=false (execute action Open steps with the current input path, retaining other Open options; without an executed Open the input fails),"includeNonImages":bool=false (folder inputs report unsupported files as per-file errors instead of skipping them; arrays are unchanged),"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; ordinary outputs are flat; namingComponents can retain subfolders when flatOutput is false),"destination":"folder|saveAndClose"="folder" (Save and Close writes the same source path/format; rejects already-open sources and changed result document identity),"output":folder? (required only for folder; omit for Save and Close),"format":"same|png|jpg|psd|tiff|…"="same" (Save and Close requires same),"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"namingComponents":[{"component":choice}|{"text":str}]? (up to six; the exact filename, with no automatic extension),"compatibility":{"macintosh":bool,"windows":bool,"unix":bool}?,"flatOutput":bool=false (structured naming only; put every result directly under output, dropping its subfolder path; duplicate output names make later results error entries),"overrideSaveAs":bool? (recorded routing; requires a directly represented Save configuration),"stopOnError":bool=false (report the first failed input and stop; earlier outputs remain saved),"naming":string? (blank keeps source names; {name}/{NAME}/{Name}, {serial}/{serial:1..4}, {letter}/{LETTER}, {date} and date formats, {ext}/{EXT}; template determines the whole filename),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool? (Folder default false; Save and Close default true, with false refused when TIFF layer data would be lost),"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"calledActions":[{"name","steps"}…]? (internal action references; default: the session's; a named source is selected from the caller)} → {files, errors, stopped?, skipped?, errorLog?, documents?} (structured naming, includeNonImages and Save and Close refuse duplicate results; ordinary native templates keep the later result)"##, + r##"{"action":name|index|[steps]? (used only when steps is absent; named actions exclude disabled steps),"set":str? (for a named action),"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action; a Save, Save As or Save a Copy step marks the document written to output, in this command's format and options: as the save found it if it was closed or another document is active at the end, else as the action leaves it; with no such step, the active document at the end),"source":"folder|open"="folder" (open runs on live documents),"input":folder|[paths] (folder source),"errorLog":path? (blank disables it),"suppressOpenOptions":bool=false (use caller RAW defaults and fully supported prior XMP without a dialog; unsupported prior settings or unanswerable profile questions fail the input),"suppressWarnings":bool=false (apply caller opening policy without profile questions; import diagnostics remain in notices),"overrideOpen":bool=false (execute action Open steps with the current input path, retaining other Open options; without an executed Open the input fails),"includeNonImages":bool=false (folder inputs report unsupported files as per-file errors instead of skipping them; arrays are unchanged),"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; ordinary outputs are flat; namingComponents can retain subfolders when flatOutput is false),"destination":"folder|saveAndClose"="folder" (Save and Close writes the same source path/format; rejects already-open sources and changed result document identity),"output":folder? (required only for folder; omit for Save and Close),"format":"same|png|jpg|psd|tiff|…"="same" (Save and Close requires same),"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"namingComponents":[{"component":choice}|{"text":str}]? (up to six; the exact filename, with no automatic extension),"compatibility":{"macintosh":bool,"windows":bool,"unix":bool}?,"flatOutput":bool=false (structured naming only; put every result directly under output, dropping its subfolder path; duplicate output names make later results error entries),"overrideSaveAs":bool? (recorded routing; requires a directly represented Save configuration),"stopOnError":bool=false (report the first failed input and stop; earlier outputs remain saved),"naming":string? (blank keeps source names; {name}/{NAME}/{Name}, {serial}/{serial:1..4}, {letter}/{LETTER}, {date} and date formats, {ext}/{EXT}; template determines the whole filename),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool? (Folder default false; Save and Close default true, with false refused when TIFF layer data would be lost),"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"maximizeCompatibility":bool? (PSD/PSB: write the real merged image; unset, Preferences > File Handling > Maximize PSD and PSB File Compatibility),"calledActions":[{"name","steps"}…]? (internal action references; default: the session's; a named source is selected from the caller)} → {files, errors, stopped?, skipped?, errorLog?, documents?} (structured naming, includeNonImages and Save and Close refuse duplicate results; ordinary native templates keep the later result)"##, native, batch ), @@ -358,7 +358,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"maximizeCompatibility":bool? (PSD/PSB: write the real merged image; unset, Preferences > File Handling > Maximize PSD and PSB File Compatibility),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -386,7 +386,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tiffTransparency":bool?,"tiffPyramid":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain),"maximizeCompatibility":bool? (PSD/PSB: write the real merged image; unset, Preferences > File Handling > Maximize PSD and PSB File Compatibility)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/cmd/file_cmds/save.rs b/crates/engine/src/cmd/file_cmds/save.rs index 665a8bbe9a..a262a83a1c 100644 --- a/crates/engine/src/cmd/file_cmds/save.rs +++ b/crates/engine/src/cmd/file_cmds/save.rs @@ -72,7 +72,8 @@ impl SaveOpts { /// `jpegFormat` it doesn't name or a `tgaBits` other than 24 or 32 is an error); the image /// previews and the PSD merged image from the session's File Handling preferences (Ask When /// Saving has no dialog here, so it saves them, as Photoshop's Save As checkbox and its - /// Maximize Compatibility checkbox both start on). The `.pcraft` full-size composite comes from + /// Maximize Compatibility checkbox both start on); `maximizeCompatibility` decides the merged + /// image for this save instead. The `.pcraft` full-size composite comes from /// `pcraftFullComposite`, or without it from the File Handling preference. pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { let file_handling = &s.prefs().file_handling; @@ -88,7 +89,7 @@ impl SaveOpts { tiff_pyramid: tiff_bool_param(p, "tiffPyramid", cmd)?.unwrap_or(false), dds: dds_param(p, cmd)?.unwrap_or_default(), image_previews: file_handling.image_previews != crate::prefs::Ask::Never, - psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, + psd_maximize_compatibility: maximize_compatibility_param(p, cmd)?.unwrap_or(file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never), pcraft_full_composite: pcraft_full_composite_param(p, cmd)?.unwrap_or(file_handling.pcraft_full_composite), }) } @@ -131,6 +132,19 @@ pub fn pcraft_full_composite_param(p: &Value, cmd: &str) -> Result> } } +/// One save's Maximize Compatibility in `p` (`"maximizeCompatibility": bool`, Photoshop's checkbox +/// when saving a PSD/PSB; absent or null: the File Handling preference decides). Anything else is +/// a bad parameter of command `cmd` (#8064). +pub fn maximize_compatibility_param(p: &Value, cmd: &str) -> Result> { + match p.get("maximizeCompatibility") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_bool() + .map(Some) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`maximizeCompatibility` must be true or false, got {v}") }), + } +} + /// The TIFF compression in `p` (`"tiffCompression": "none" | "lzw" | "zip"`, Photoshop's TIFF /// Options; absent or null: none). Anything else is a bad parameter of command `cmd` (#5301). pub fn tiff_compression_param(p: &Value, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs index 26e172ffd6..d42d333128 100644 --- a/crates/engine/src/cmd/file_cmds/tests.rs +++ b/crates/engine/src/cmd/file_cmds/tests.rs @@ -1875,6 +1875,41 @@ fn full_composite_preference_controls_the_pcraft_full_size_composite() { } } +/// #8064: `maximizeCompatibility` decides one save's PSD/PSB merged image over the preference +/// (Photoshop's Maximize Compatibility checkbox); unset, the preference decides. Anything but a +/// boolean is refused before anything is written. +#[test] +fn maximize_compatibility_param_overrides_the_preference_for_one_save() { + use crate::prefs::Ask; + let dir = tmp("maximize-param"); + let mut s = session(300, 200, 16); + s.execute("layer.new.layer", json!({})).unwrap(); + let real_merged = |path: &str| photocraft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); + for (ask, param, expect) in [ + (Ask::Always, json!(false), false), + (Ask::Never, json!(true), true), + (Ask::Ask, json!(false), false), + (Ask::Never, json!(null), false), + (Ask::Always, json!(null), true), + ] { + s.edit_prefs(|p| p.file_handling.maximize_psd_compatibility = ask); + for ext in ["psd", "psb"] { + let out = join(&dir, &format!("{ask:?}-{param}.{ext}")); + s.execute("file.saveACopy", json!({"path": out, "maximizeCompatibility": param})).unwrap(); + assert_eq!(real_merged(&out), Some(expect), "{ask:?} {param} {ext}"); + } + } + let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty())); + let before = state(&s); + for bad in [json!("no"), json!(0), json!(1), json!([true])] { + let out = join(&dir, "bad.psd"); + let r = s.execute("file.saveACopy", json!({"path": out, "maximizeCompatibility": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + assert_eq!(state(&s), before, "{bad}: the document is as it was"); + } +} + #[test] fn open_as_forces_the_decoder() { let dir = tmp("openas"); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index e3756a39ea..3a22449782 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -1422,6 +1422,50 @@ fn automation_save_passes_targa_bits_without_asking() { assert_eq!(written.borrow().len(), 3, "a bad tgaBits writes nothing"); } +/// #8064: `app.save` takes `maximizeCompatibility` for a PSD/PSB's merged image over Preferences › +/// File Handling; unset, the preference decides. Anything but a boolean writes nothing. +#[test] +fn app_save_takes_maximize_compatibility() { + use std::cell::RefCell; + use std::rc::Rc; + let asked: Rc>> = Rc::default(); + let written: Rc>> = Rc::default(); + let (a, w) = (asked.clone(), written.clone()); + let services = crate::Services { + export: Some(Box::new(move |_d: &photocraft_doc::Document, _p: &str, s: &crate::ExportSettings| { + a.borrow_mut().push(s.psd_maximize_compatibility); + Ok((b"out".to_vec(), Vec::new())) + })), + automation_write: Some(Box::new(move |path: &str, _b: &[u8]| { + w.borrow_mut().push(path.to_string()); + Ok(()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let saves = [ + json!({"path": "a.psd"}), + json!({"path": "b.psd", "maximizeCompatibility": false}), + json!({"path": "c.psd", "maximizeCompatibility": true}), + json!({"path": "d.psd", "maximizeCompatibility": null}), + ]; + for params in &saves { + let r = call(&mut app, &ctx, "app.save", params.clone()); + assert_eq!(r["result"]["path"], params["path"], "{r}"); + } + app.session.edit_prefs(|p| p.file_handling.maximize_psd_compatibility = photocraft_engine::prefs::Ask::Never); + for params in [json!({"path": "e.psd"}), json!({"path": "f.psd", "maximizeCompatibility": true})] { + let r = call(&mut app, &ctx, "app.save", params.clone()); + assert_eq!(r["result"]["path"], params["path"], "{r}"); + } + assert_eq!(*asked.borrow(), [true, false, true, true, false, true]); + let r = call(&mut app, &ctx, "app.save", json!({"path": "g.psd", "maximizeCompatibility": "no"})); + assert!(r["error"].as_str().unwrap().contains("maximizeCompatibility"), "{r}"); + assert_eq!(written.borrow().len(), 6, "a bad maximizeCompatibility writes nothing"); +} + /// `app.save` takes JPEG Options (`quality` 0–12, `jpegFormat`) and never stops at the prompt; /// a value it can't read is an error that writes nothing. #[test] diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index a9d511fb48..58317bf3d4 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -8,15 +8,17 @@ pub(crate) const SAVE_OPTIONS_OPEN: &str = "Answer the TIFF, Targa, DDS or JPEG /// The format answers a save takes instead of asking: the Targa bits per pixel (`"tgaBits"`), /// the DDS options (`"ddsCompression"`, `"ddsMipmaps"`), JPEG Options (`"quality"` 0–12, -/// `"jpegFormat"`) and TIFF Options (`"tiffCompression"`, `"tiffTransparency"`, `"tiffPyramid"`, -/// `"tiffLayers"`). `None` asks in Save As and takes the default in automation saves, which never -/// prompt. +/// `"jpegFormat"`), TIFF Options (`"tiffCompression"`, `"tiffTransparency"`, `"tiffPyramid"`, +/// `"tiffLayers"`) and a PSD/PSB's Maximize Compatibility (`"maximizeCompatibility"`). `None` +/// asks in Save As and takes the default in automation saves, which never prompt (Maximize +/// Compatibility: Preferences › File Handling decides). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct FormatChoices { pub tga_bits: Option, pub dds: Option, pub jpeg: Option, pub tiff: Option, + pub maximize_compatibility: Option, } impl FormatChoices { @@ -27,6 +29,7 @@ impl FormatChoices { dds: dds_options_ui::options_param(params, cmd)?, jpeg: jpeg_options_ui::choice_param(params, cmd)?, tiff: tiff_options_ui::choice_param(params, cmd)?, + maximize_compatibility: maximize_param(params, cmd)?, }) } @@ -38,12 +41,18 @@ impl FormatChoices { dds: dds_options_ui::options_param(params, cmd)?, jpeg: jpeg_options_ui::recorded_choice(params, cmd)?, tiff: tiff_options_ui::choice_param(params, cmd)?, + maximize_compatibility: maximize_param(params, cmd)?, }) } /// The export settings with these answers applied. pub fn apply(self, settings: ExportSettings) -> ExportSettings { - let settings = ExportSettings { tga_bits: self.tga_bits.or(settings.tga_bits), dds: self.dds.unwrap_or(settings.dds), ..settings }; + let settings = ExportSettings { + tga_bits: self.tga_bits.or(settings.tga_bits), + dds: self.dds.unwrap_or(settings.dds), + psd_maximize_compatibility: self.maximize_compatibility.unwrap_or(settings.psd_maximize_compatibility), + ..settings + }; let settings = match self.tiff { Some(t) => t.apply(settings), None => settings, @@ -68,9 +77,17 @@ impl FormatChoices { if let Some(t) = self.tiff { t.record(step); } + if let Some(maximize) = self.maximize_compatibility { + step["maximizeCompatibility"] = maximize.into(); + } } } +/// `"maximizeCompatibility"` in command or control call `cmd`'s params, read as the engine reads it. +fn maximize_param(params: &Value, cmd: &str) -> Result, String> { + photocraft_engine::file_cmds::maximize_compatibility_param(params, cmd).map_err(|e| e.to_string()) +} + /// Decode a file: (document, warnings about anything approximated or dropped). pub type ImportFn = Box Result<(Document, Vec), String>>; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2ab869ff03..c591525984 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -113,7 +113,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i another document becomes active before the capture, the request fails with an error rather than saving the wrong document - `ui.focus`: bring the main window to the front -- `app.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, quality?, jpegFormat?, tiffCompression?, tiffTransparency?, tiffPyramid?, tiffLayers?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; TIFF: `tiffCompression` `none`, `lzw` or `zip`, `tiffTransparency` and `tiffPyramid` bools; automation never shows JPEG or TIFF Options): relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths beneath the canonical or configured root are accepted, while `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS, JPEG or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) +- `app.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, quality?, jpegFormat?, tiffCompression?, tiffTransparency?, tiffPyramid?, tiffLayers?, maximizeCompatibility?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; TIFF: `tiffCompression` `none`, `lzw` or `zip`, `tiffTransparency` and `tiffPyramid` bools; automation never shows JPEG or TIFF Options; PSD/PSB: `maximizeCompatibility` false writes Photoshop's placeholder instead of the merged image (Bitmap, Indexed Color and Duotone documents still write the real image); unset or null follows Preferences › File Handling: Always and Ask When Saving write the real merged image, Never the placeholder, and `app.save` never opens a compatibility dialog): relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths beneath the canonical or configured root are accepted, while `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none; without it, 32 with an alpha channel or transparency, else 24. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS, JPEG or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) - `app.quit` ## Engine commands @@ -386,7 +386,7 @@ How each MCP tool maps onto control methods in bridge mode: | `doc_export_layers {dir, …}` | `engine.execute {command: "file.export.layersToFiles", params}` | | `doc_export_data_sets {dir, …}` | `engine.execute {command: "file.export.dataSetsAsFiles", params}` | | `doc_import_data_sets {path, …}` | `engine.execute {command: "file.import.variableDataSets", params}` | -| `doc_save {path?, tgaBits?, ddsCompression?, ddsMipmaps?}` / `doc_export {path, tgaBits?, ddsCompression?, ddsMipmaps?}` | `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?}` | +| `doc_save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, maximizeCompatibility?}` / `doc_export {path, tgaBits?, ddsCompression?, ddsMipmaps?, maximizeCompatibility?}` | `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, maximizeCompatibility?}` | | `doc_render_preview {max_side?}` | `ui.screenshot`, returned directly as PNG image content | | `session_list`, `ui_inspect` | `ui.inspect` | | `ui_screenshot {max_side?}` | `ui.screenshot`, returned as PNG image content | @@ -452,7 +452,7 @@ stdio, and a value that is not a port is a usage error naming the variable. Impl | `doc.exportLayers` | `{dir, format? (png), prefix?, visibleOnly? (true), quality?, index?}` → `{files, skipped}` (plus `count` for workspace exports): one file per exported top-level layer, named like `file.export.layersToFiles` names them. `count` is present only for workspace exports and counts written files only. `skipped` is always an array (empty when nothing is omitted), in top-to-bottom order, of `{layer, name, reason}`: the session layer id, original unsanitized name, and `"hidden by visibleOnly"` or `"adjustment layer has no pixels of its own"`. Visibility takes precedence for hidden adjustments; `visibleOnly:false` exports hidden non-adjustment layers. Groups export merged; their children are not separate exports or skips. Existing filenames/numbering are unchanged. `dir` resolves beneath the write root, so the ambient command stays refused. The bytes come from the command's own core, so both paths write the same files; the write capability refuses to overwrite an existing file. Encode/write failures are errors, not skips | | `doc.exportDataSets` | `{dir, format? (png), dataSets? (all), naming? ("{name}\|{index}\|{document}"), index?}` → `{files, count}`: the capability-scoped `file.export.dataSetsAsFiles`. The batch is planned first and colliding names are refused before any file is written, as in the command | | `doc.importDataSets` | `{path, delimiter? (,), index?}` → `{imported}`: the capability-scoped `file.import.variableDataSets`; the CSV is read beneath the read root | -| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tiffCompression?, tiffTransparency?, tiffPyramid?, tgaBits?, ddsCompression?, ddsMipmaps?, pcraftFullComposite?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tiffCompression` (`none`, `lzw`, `zip`), `tiffTransparency` (bool: Save Transparency; unset, kept unless the document has alpha or spot channels) and `tiffPyramid` (bool) are TIFF Options; `tgaBits` 24 or 32 sets a TGA's bits per pixel, and `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, as `app.save` does; `pcraftFullComposite: true` also writes the flattened document at its canvas size into a `.pcraft` file as `composite/full.png`; the save may leave it out in the cases listed in [the format guide](../book/src/formats/pcraft.md), and the manifest's `full_composite` member names it when written). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | +| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?, tiffCompression?, tiffTransparency?, tiffPyramid?, tgaBits?, ddsCompression?, ddsMipmaps?, pcraftFullComposite?, maximizeCompatibility?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tiffCompression` (`none`, `lzw`, `zip`), `tiffTransparency` (bool: Save Transparency; unset, kept unless the document has alpha or spot channels) and `tiffPyramid` (bool) are TIFF Options; `tgaBits` 24 or 32 sets a TGA's bits per pixel, `ddsCompression`/`ddsMipmaps` a DDS texture's compression and mip chain, and `maximizeCompatibility: false` writes a layered PSD/PSB with Photoshop's placeholder instead of the merged image, except for Bitmap, Indexed Color and Duotone documents, as `app.save` does; `pcraftFullComposite: true` also writes the flattened document at its canvas size into a `.pcraft` file as `composite/full.png`; the save may leave it out in the cases listed in [the format guide](../book/src/formats/pcraft.md), and the manifest's `full_composite` member names it when written). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | | `doc.inspect` | `{index?}`: same JSON as `document.inspect` | | `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` | | `doc.select` / `doc.close` | `{index}` / `{index?}` | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bc3c84a524..2665b81c8b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** minor (scripted PSD/PSB saves take `maximizeCompatibility`, and the CLI `--no-maximize-compatibility`, #8064; TIFF Options on Save As and Save a Copy: Image Compression None/LZW/ZIP, Save Image Pyramid, Save Transparency and Discard Layers, also `tiffTransparency`/`tiffPyramid` for scripts, the CLI and MCP, #8827; `.atn`: 145 supported events including Shear after Actions batch 12 (Groups A–I, command options, version 12 files, reference forms); the supported table and measured reference are in [atn.md](atn.md); TIFF: YCbCr images open as RGB, including JPEG-compressed scans with subsampled chroma, #10316; TIFFs up to 300,000 px a side open, decoded a group of rows at a time into the layer, #8169; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; PSD Filter Gallery: Halftone Pattern dot/circle/line settings survive PSD/PSB saves (#8940); Save for Web writes WebP, lossy or lossless, #6391; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency, #8877; layered TIFF writes the TySh text version and descriptor in PSD big-endian order while the header, matrix, warp and bounds keep container order, #9098; layered TIFF writes Photoshop's 14-byte global layer mask, so Photoshop opens the layers instead of only the composite, #9095; legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding; Amiga IFF write: 24-plane ILBM, Save As `.iff`; PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053, #7415 split; TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency) · **Target:** Adobe Photoshop 2026 **PSD Filter Gallery (#8940, 2026-10-23):** Halftone Pattern maps its size, contrast and dot/circle/line pattern as editable settings on import and export, with the reference application's descriptor keys (`HlSz`, `Cntr`, `ScrT`: `ScrD`/`ScrC`/`ScrL`). PSD/PSB round trips pass at 8/16/32 bits. This does not claim pixel equivalence for the existing renderer. Unknown or malformed settings remain verbatim on import. @@ -151,6 +151,7 @@ The original reference entry retains its source date and historical totals; curr | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Scripted PSD/PSB saves take `maximizeCompatibility` (Save, Save As, Save a Copy, `app.save`, serve `doc.save`, MCP `doc_save`/`doc_export`, Batch, Image Processor, Layers to Files) over Preferences › File Handling for one save, and the CLI `--no-maximize-compatibility` (#8064). Layer channels and the merged image were already RLE (ZIP with prediction at 32-bit), as Photoshop writes them (#8645) | | 2026-11-02 | minor | Actions Shear (`Shr `) loads, plays and saves the measured curve points and edge mode; unsafe subranges and unrepresentable forms remain kept steps (#10605). | | 2026-11-01 | minor | `.atn`: 23 newer measured forms mapped, including explicit Link/Unlink, Smart Object file operations, Make Type to Shape, radius-only Shape Blur, Artboard from Group and the Crop Photos parent; supported in batch 13; Link Layers keeps the measured toggle. Artboard forms with unmeasured settings and generated Crop helper steps remain kept. | | 2026-11-01 | minor | Recorded direct Gradient Map and Lab-range Replace Color load, play and retain their measured settings on save; unsupported forms remain preserved (part of #10607). |