mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 12:22:22 +00:00
Merge pull request #10001 from indy2kro/feat/8064-psd-maximize-compatibility-param
Saves take maximizeCompatibility for a PSD/PSB's merged image (#8064)
This commit is contained in:
16 files changed
+269
-19
No files matched your search
Generated
+1
@@ -5247,6 +5247,7 @@ dependencies = [
|
||||
"photocraft-engine",
|
||||
"photocraft-format",
|
||||
"photocraft-io",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"serde",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, …)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -113,6 +113,11 @@ pub struct ExportLayersParams {
|
||||
/// Encoder quality, 0..12, for the formats that take one.
|
||||
#[serde(default)]
|
||||
pub quality: Option<f64>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
#[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<bool>,
|
||||
/// 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<bool>,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Option<bool>>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Option<bool>> {
|
||||
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<Option<photocraft_codecs::TiffCompression>> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<RefCell<Vec<bool>>> = Rc::default();
|
||||
let written: Rc<RefCell<Vec<String>>> = 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]
|
||||
|
||||
@@ -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<photocraft_io::TgaBits>,
|
||||
pub dds: Option<photocraft_io::DdsOptions>,
|
||||
pub jpeg: Option<jpeg_options_ui::JpegChoice>,
|
||||
pub tiff: Option<tiff_options_ui::TiffChoice>,
|
||||
pub maximize_compatibility: Option<bool>,
|
||||
}
|
||||
|
||||
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<Option<bool>, 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<dyn Fn(&str, &[u8], &photocraft_io::ImportOptions) -> Result<(Document, Vec<String>), String>>;
|
||||
|
||||
|
||||
@@ -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?}` |
|
||||
|
||||
@@ -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 wLine truncated
|
||||
> **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, Line truncated
|
||||
|
||||
**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). |
|
||||
|
||||
Reference in new issue
Block a user