mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
TGA: targa options asks for 24 or 32 bits/pixel, same as in Photoshop (#2760)
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
1 parent
273ff014e3
commit
ac19db1046
44 files changed
+1026
-93
No files matched your search
@@ -15,16 +15,18 @@ pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
--tga-bits 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.
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
|
||||
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Apply an action list to every image in a directory. Steps are [id, params] pairs,
|
||||
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
|
||||
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
|
||||
@@ -65,12 +67,17 @@ struct Subcommand {
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand { name: "convert", values: &["--format", "--quality"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
|
||||
Subcommand { name: "run", values: &["--new", "--cmd", "--params", "--out", "--format", "--quality"], bare: &["--tiff-layers"], run: run_cmds },
|
||||
Subcommand {
|
||||
name: "run",
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
|
||||
bare: &["--tiff-layers"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality"],
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
|
||||
bare: &["--in-place", "--tiff-layers"],
|
||||
run: batch,
|
||||
},
|
||||
@@ -203,6 +210,9 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
o.encode.webp_quality = q;
|
||||
o.encode.webp_lossless = false;
|
||||
}
|
||||
if let Some(b) = a.get("--tga-bits") {
|
||||
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
|
||||
@@ -378,6 +378,32 @@ fn quality_outside_1_to_100_is_refused() {
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
|
||||
/// refused and nothing is written.
|
||||
#[test]
|
||||
fn tga_bits_choose_the_targa_depth() {
|
||||
let d = tmp("tga-bits");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] {
|
||||
let out = d.join("a.tga");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--tga-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}");
|
||||
}
|
||||
for bad in ["16", "0", "abc", ""] {
|
||||
let out = d.join("bad.tga");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
|
||||
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
|
||||
/// anything is written, unless `--in-place` asks for it.
|
||||
#[test]
|
||||
|
||||
@@ -350,6 +350,7 @@ fn services(inbox: Inbox) -> Services {
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
file_dialog: Some(Box::new(|request, _parent, reply| match request {
|
||||
|
||||
@@ -197,6 +197,7 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
opts
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
|
||||
|
||||
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
|
||||
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
|
||||
|
||||
## Implemented decode limits
|
||||
|
||||
|
||||
@@ -97,8 +97,9 @@ impl Headless {
|
||||
}
|
||||
"doc.new" => self.command_run("file.new", p),
|
||||
"doc.save" => {
|
||||
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
|
||||
let mut opts =
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), ..Default::default() };
|
||||
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
|
||||
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
|
||||
opts.encode.webp_quality = q.clamp(1, 100) as u8;
|
||||
|
||||
@@ -90,6 +90,10 @@ pub struct SaveParams {
|
||||
/// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF.
|
||||
#[serde(default, rename = "tiffLayers")]
|
||||
pub tiff_layers: bool,
|
||||
/// TGA: 24 or 32 bits per pixel. 32 writes the alpha channel (or else the transparency) as the
|
||||
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
|
||||
#[serde(default, rename = "tgaBits")]
|
||||
pub tga_bits: Option<u8>,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
@@ -800,11 +804,16 @@ impl PhotocraftMcp {
|
||||
}
|
||||
|
||||
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
|
||||
let tga_bits = match p.tga_bits.map(|b| (b, photocraft_io::TgaBits::from_bits(u64::from(b)))) {
|
||||
None => None,
|
||||
Some((_, Some(bits))) => Some(bits),
|
||||
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))),
|
||||
};
|
||||
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), so the extra options are headless-only. Saying so beats saving with defaults
|
||||
// while the caller believes their quality or format was applied.
|
||||
// does) and the Targa bits, 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"),
|
||||
@@ -817,15 +826,18 @@ impl PhotocraftMcp {
|
||||
if !unsupported.is_empty() {
|
||||
return Ok(fail(format!("bridge mode saves with the app's current settings; `{}` need headless mode", unsupported.join("`, `"))));
|
||||
}
|
||||
let params = match p.path {
|
||||
Some(path) => json!({"path": path}),
|
||||
None => json!({}),
|
||||
};
|
||||
let mut params = json!({});
|
||||
if let Some(path) = p.path {
|
||||
params["path"] = json!(path);
|
||||
}
|
||||
if let Some(bits) = tga_bits {
|
||||
params["tgaBits"] = json!(bits.bits());
|
||||
}
|
||||
return to_result(b.call("app.save", params).await);
|
||||
}
|
||||
let Some(r) = self
|
||||
.headless_op(move |h| {
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, ..Default::default() };
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
|
||||
if let Some(q) = p.quality {
|
||||
opts.encode.jpeg_quality = q.clamp(1, 100);
|
||||
opts.encode.webp_quality = q.clamp(1, 100);
|
||||
|
||||
@@ -359,6 +359,49 @@ async fn bridge_doc_save_without_path_forwards_app_save_for_write_back() {
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// `tgaBits` reaches the app's `app.save` through the bridge; a value other than 24 or 32 is
|
||||
/// refused before anything is sent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_doc_save_forwards_targa_bits() {
|
||||
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.tga", "tgaBits": 24})).await);
|
||||
assert_eq!(saved, json!({"saved": {"path": "out.tga", "tgaBits": 24}}));
|
||||
let r = call(&client, "doc_save", json!({"path": "out.tga", "tgaBits": 16})).await;
|
||||
assert_eq!(r.is_error, Some(true), "{}", text(&r));
|
||||
assert!(text(&r).contains("24 or 32"), "{}", text(&r));
|
||||
client.cancel().await.unwrap();
|
||||
app.abort();
|
||||
}
|
||||
|
||||
/// Headless `doc_save`/`doc_export` write the TGA bits per pixel asked for (byte 16 of the
|
||||
/// header); unset, a document with an alpha channel is written as 32-bit.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn headless_doc_save_writes_targa_bits() {
|
||||
let dir = tmp("tga-bits");
|
||||
let client = connect(headless_in(&dir)).await;
|
||||
call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await;
|
||||
for id in ["select.all", "select.saveSelection"] {
|
||||
let r = call(&client, "command_run", json!({"id": id, "params": {}})).await;
|
||||
assert_ne!(r.is_error, Some(true), "{}", text(&r));
|
||||
}
|
||||
for (tool, params, bpp) in [
|
||||
("doc_export", json!({"path": "auto.tga"}), 32),
|
||||
("doc_export", json!({"path": "flat.tga", "tgaBits": 24}), 24),
|
||||
("doc_save", json!({"path": "alpha.tga", "tgaBits": 32}), 32),
|
||||
] {
|
||||
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!(bytes[16], bpp, "{params}");
|
||||
}
|
||||
let r = call(&client, "doc_export", json!({"path": "bad.tga", "tgaBits": 8})).await;
|
||||
assert_eq!(r.is_error, Some(true), "{}", text(&r));
|
||||
assert!(!dir.join("bad.tga").exists());
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
|
||||
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
|
||||
|
||||
@@ -434,6 +434,7 @@ fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export.artboardsToFiles";
|
||||
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
|
||||
let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase();
|
||||
let save = SaveOpts::from_params(p, cmd)?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
|
||||
let doc = d.doc.clone();
|
||||
@@ -441,7 +442,7 @@ fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for one in chosen_documents(&doc, p)? {
|
||||
let name = if prefix.is_empty() { sanitize(&one.name) } else { format!("{}_{}", sanitize(&prefix), sanitize(&one.name)) };
|
||||
let path = join(&dir, &format!("{name}.{format}"));
|
||||
save_doc(&one, &path, SaveOpts::from_params(p))?;
|
||||
save_doc(&one, &path, save)?;
|
||||
files.push(path);
|
||||
}
|
||||
if files.is_empty() {
|
||||
|
||||
@@ -367,6 +367,7 @@ fn comps_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export.layerCompsToFiles";
|
||||
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
|
||||
let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase();
|
||||
let save = SaveOpts::from_params(p, cmd)?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
|
||||
let doc = d.doc.clone();
|
||||
@@ -381,7 +382,7 @@ fn comps_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_comp(&mut one, c, false);
|
||||
one.last_applied_comp = Some(c.id);
|
||||
let path = join(&dir, &format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&c.name)));
|
||||
save_doc(&one, &path, SaveOpts::from_params(p))?;
|
||||
save_doc(&one, &path, save)?;
|
||||
files.push(path);
|
||||
}
|
||||
if files.is_empty() {
|
||||
|
||||
@@ -218,19 +218,27 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
Ok(r.document)
|
||||
}
|
||||
|
||||
/// What a headless save writes beyond the format: JPEG quality and TIFF layers.
|
||||
/// What a headless save writes beyond the format: JPEG quality, TIFF layers and Targa bits.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(crate) struct SaveOpts {
|
||||
/// Photoshop's 0–12 JPEG scale.
|
||||
pub quality: Option<f64>,
|
||||
/// TIFF: keep the layers. Off unless a command's params ask (`"tiffLayers": true`).
|
||||
pub tiff_layers: bool,
|
||||
/// Targa: 24 or 32 bits per pixel (`"tgaBits"`); unset, 32 with an alpha channel or
|
||||
/// transparency, else 24.
|
||||
pub tga_bits: Option<photocraft_io::TgaBits>,
|
||||
}
|
||||
|
||||
impl SaveOpts {
|
||||
/// `quality` and `tiffLayers` from a command's params.
|
||||
pub(crate) fn from_params(p: &Value) -> Self {
|
||||
SaveOpts { quality: f64_param(p, "quality"), tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false) }
|
||||
/// `quality`, `tiffLayers` and `tgaBits` from command `cmd`'s params; a `tgaBits` other than
|
||||
/// 24 or 32 is an error.
|
||||
pub(crate) fn from_params(p: &Value, cmd: &str) -> Result<Self> {
|
||||
Ok(SaveOpts {
|
||||
quality: f64_param(p, "quality"),
|
||||
tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false),
|
||||
tga_bits: tga_bits_param(p, cmd)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn or_quality(mut self, q: f64) -> Self {
|
||||
@@ -245,11 +253,25 @@ impl From<Option<f64>> for SaveOpts {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Targa bits per pixel in `p` (`"tgaBits": 24 | 32`; absent or null: the automatic choice).
|
||||
/// Anything else is a bad parameter of command `cmd`. Shared by every save that takes it (the
|
||||
/// engine's commands, the app's Save As and control channel, the headless server).
|
||||
pub fn tga_bits_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::TgaBits>> {
|
||||
match p.get("tgaBits") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.and_then(photocraft_io::TgaBits::from_bits)
|
||||
.map(Some)
|
||||
.ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`tgaBits` must be 24 or 32, got {v}") }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes `doc` for `path`'s extension.
|
||||
pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
crate::allocation::checkpoint("saving document")?;
|
||||
let save = save.into();
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, ..Default::default() };
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, ..Default::default() };
|
||||
if let Some(q) = save.quality {
|
||||
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
|
||||
opts.encode.jpeg_quality = q;
|
||||
@@ -356,7 +378,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let px = flattened(&doc, fmt);
|
||||
doc.layers = vec![Layer::new("Background", LayerContent::Raster(px))];
|
||||
}
|
||||
let warnings = save_doc(&doc, &path, SaveOpts::from_params(p))?;
|
||||
let warnings = save_doc(&doc, &path, SaveOpts::from_params(p, "file.saveACopy")?)?;
|
||||
Ok(json!({"path": path, "warnings": warnings}))
|
||||
}
|
||||
|
||||
@@ -906,7 +928,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() };
|
||||
let r = process_files(&inputs, &output, &format, SaveOpts::from_params(p), "", &|scratch| {
|
||||
let r = process_files(&inputs, &output, &format, SaveOpts::from_params(p, cmd)?, "", &|scratch| {
|
||||
scratch.actions = actions.clone();
|
||||
for (id, params) in &steps {
|
||||
let r = scratch.execute(id, params.clone())?;
|
||||
@@ -930,7 +952,7 @@ fn image_processor(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})),
|
||||
};
|
||||
let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false);
|
||||
let r = process_files(&inputs, &output, &format, SaveOpts::from_params(p).or_quality(8.0), "", &|scratch| {
|
||||
let r = process_files(&inputs, &output, &format, SaveOpts::from_params(p, cmd)?.or_quality(8.0), "", &|scratch| {
|
||||
if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) {
|
||||
scratch.execute("image.mode.rgb", json!({}))?;
|
||||
}
|
||||
@@ -1071,6 +1093,7 @@ fn layers_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
|
||||
let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase();
|
||||
let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true);
|
||||
let save = SaveOpts::from_params(p, cmd)?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
|
||||
let doc = d.doc.clone();
|
||||
@@ -1087,7 +1110,7 @@ fn layers_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
only.clipped = false;
|
||||
one.layers = vec![only];
|
||||
let path = join(&dir, &format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&l.name)));
|
||||
save_doc(&one, &path, SaveOpts::from_params(p))?;
|
||||
save_doc(&one, &path, save)?;
|
||||
files.push(path);
|
||||
}
|
||||
Ok(json!({"files": files}))
|
||||
@@ -1397,7 +1420,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),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default)}"##,
|
||||
r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"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)}"##,
|
||||
native_doc,
|
||||
save_a_copy
|
||||
),
|
||||
@@ -1460,7 +1483,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch…",
|
||||
&["File", "Automate"],
|
||||
None,
|
||||
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
|
||||
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
|
||||
native,
|
||||
batch
|
||||
),
|
||||
@@ -1469,7 +1492,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Image Processor…",
|
||||
&["File", "Scripts"],
|
||||
None,
|
||||
r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"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],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"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
|
||||
),
|
||||
@@ -1497,7 +1520,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,"quality":0..12?,"tiffLayers":bool=false} → {files}"##,
|
||||
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?} → {files}"##,
|
||||
native_doc,
|
||||
layers_to_files
|
||||
),
|
||||
|
||||
@@ -135,6 +135,42 @@ fn save_a_copy_keeps_path_and_dirty_state() {
|
||||
assert!(s.execute("file.saveACopy", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// `tgaBits` picks the Targa's bits per pixel (byte 16 of the header); unset, a document with an
|
||||
/// alpha channel is written as 32-bit, with the channel as the alpha.
|
||||
#[test]
|
||||
fn save_a_copy_writes_the_targa_bits_asked_for() {
|
||||
let dir = tmp("tga-bits");
|
||||
let mut s = session(20, 10, 8);
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.execute("select.saveSelection", json!({})).unwrap();
|
||||
for (params, bpp) in [(json!({}), 32), (json!({"tgaBits": 24}), 24), (json!({"tgaBits": 32}), 32), (json!({"tgaBits": null}), 32)] {
|
||||
let out = join(&dir, "a.tga");
|
||||
let mut p = params.clone();
|
||||
p["path"] = json!(out);
|
||||
s.execute("file.saveACopy", p).unwrap();
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert_eq!(bytes[16], bpp, "{params}");
|
||||
let back = photocraft_io::import("a.tga", &bytes).unwrap().document;
|
||||
assert_eq!(back.channels.len(), usize::from(bpp == 32), "{params}: the selection reopens as Alpha 1");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `tgaBits` other than 24 or 32 is a bad parameter, reported before anything is written.
|
||||
#[test]
|
||||
fn bad_targa_bits_are_refused_before_writing() {
|
||||
let dir = tmp("tga-bad");
|
||||
let mut s = session(8, 8, 8);
|
||||
for bad in [json!(16), json!(0), json!(-32), json!(32.5), json!("32"), json!(true), json!([32])] {
|
||||
let out = join(&dir, "a.tga");
|
||||
let r = s.execute("file.saveACopy", json!({"path": out, "tgaBits": bad}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}");
|
||||
assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written");
|
||||
let r = s.execute("file.export.layersToFiles", json!({"dir": dir, "format": "tga", "tgaBits": bad}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "{bad}: no layer written");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_as_forces_the_decoder() {
|
||||
let dir = tmp("openas");
|
||||
|
||||
@@ -402,7 +402,7 @@ fn lens_batch(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Batch default: the EXIF-driven profile, as Photoshop's dialog suggests.
|
||||
m.entry("profile").or_insert(json!("auto"));
|
||||
}
|
||||
let r = crate::file_cmds::process_files(&inputs, &output, &format, crate::file_cmds::SaveOpts::from_params(p), "", &|scratch| {
|
||||
let r = crate::file_cmds::process_files(&inputs, &output, &format, crate::file_cmds::SaveOpts::from_params(p, LENS_BATCH)?, "", &|scratch| {
|
||||
if scratch.active().is_some_and(|d| d.doc.layers.len() > 1) {
|
||||
scratch.execute("layer.flattenImage", json!({}))?;
|
||||
}
|
||||
|
||||
+50
-17
@@ -4,11 +4,11 @@ use std::sync::Arc;
|
||||
|
||||
use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample};
|
||||
use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_doc::{AlphaChannel, Document, Layer, LayerContent};
|
||||
use photocraft_geom::{Rect, Size, TILE_SIZE};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::{ExportOptions, ExportResult, ImportResult, IoError, XmpEmbed};
|
||||
use crate::{ExportOptions, ExportResult, ImportResult, IoError, TgaBits, XmpEmbed};
|
||||
|
||||
/// Bytes per band when converting or exporting a band of rows at a time.
|
||||
const BAND_BYTES: usize = 32 << 20;
|
||||
@@ -418,29 +418,62 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu
|
||||
}
|
||||
}
|
||||
if format == Format::Tga {
|
||||
img = tga_alpha_channel(doc, img, &mut warnings)?;
|
||||
img = tga_image(doc, img, opts.tga_bits, &mut warnings)?;
|
||||
}
|
||||
encode_image(&img, format, opts, warnings)
|
||||
}
|
||||
|
||||
/// Photoshop's 32-bit Targa takes its alpha from the document's alpha channel, not from layer
|
||||
/// transparency: with exactly one alpha channel (spot channels don't count) an RGB file gets
|
||||
/// that channel's values as its alpha, and its colours are the composite over white. With two or
|
||||
/// more Photoshop writes no channel (an opaque alpha), so the image is left as it is. Grayscale
|
||||
/// Targas have no alpha in Photoshop either.
|
||||
fn tga_alpha_channel(doc: &Document, img: Image, warnings: &mut Vec<String>) -> Result<Image, IoError> {
|
||||
/// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an
|
||||
/// alpha channel (spot channels don't count), else 24.
|
||||
pub fn tga_default_bits(doc: &Document) -> TgaBits {
|
||||
if doc.channels.iter().any(|c| c.spot.is_none()) { TgaBits::Bits32 } else { TgaBits::Bits24 }
|
||||
}
|
||||
|
||||
/// The RGB image a Targa of `bits` per pixel holds (`None`: 32 with an alpha channel or
|
||||
/// transparency, else 24). 24 bits are the colours alone, composited over white where the layers
|
||||
/// are transparent. 32 add an alpha: as in Photoshop, the document's alpha channel when it has
|
||||
/// exactly one (spot channels don't count); with none, or several (Photoshop then writes none of
|
||||
/// them), the layers' transparency, fully opaque without any. Colours are written as they are,
|
||||
/// never premultiplied or cleared under a zero alpha: Targa alphas often hold data such as a
|
||||
/// game texture's specular mask. Grayscale Targas have no alpha channel in Photoshop and are left
|
||||
/// as they are.
|
||||
fn tga_image(doc: &Document, img: Image, bits: Option<TgaBits>, warnings: &mut Vec<String>) -> Result<Image, IoError> {
|
||||
if !img.layout().is_rgb() {
|
||||
if let Some(bits) = bits {
|
||||
warnings.push(format!("{}-bit Targa applies to RGB images; the grayscale image is written as it is", bits.bits()));
|
||||
}
|
||||
return Ok(img);
|
||||
}
|
||||
let mut alphas = doc.channels.iter().filter(|c| c.spot.is_none());
|
||||
let channel = match (alphas.next(), alphas.next()) {
|
||||
(Some(c), None) => c,
|
||||
(Some(_), Some(_)) => {
|
||||
warnings.push("Targa holds one alpha channel; with several, none was written (as in Photoshop)".into());
|
||||
return Ok(img);
|
||||
let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect();
|
||||
let bits = bits.unwrap_or(if !alphas.is_empty() || img.layout().has_alpha() { TgaBits::Bits32 } else { TgaBits::Bits24 });
|
||||
match (bits, alphas.as_slice()) {
|
||||
(TgaBits::Bits24, alphas) => {
|
||||
if !alphas.is_empty() {
|
||||
let names: Vec<String> = alphas.iter().map(|c| format!("\"{}\"", c.name)).collect();
|
||||
warnings.push(format!("a 24-bit Targa has no alpha; not saved: {}", names.join(", ")));
|
||||
}
|
||||
if !img.layout().has_alpha() {
|
||||
return Ok(img);
|
||||
}
|
||||
warnings.push("transparency composited over white; a 24-bit Targa has no alpha".into());
|
||||
matte_over_white(&img)
|
||||
}
|
||||
_ => return Ok(img),
|
||||
};
|
||||
(TgaBits::Bits32, [channel]) => tga_with_alpha_channel(doc, img, channel, warnings),
|
||||
(TgaBits::Bits32, alphas) => {
|
||||
if !alphas.is_empty() {
|
||||
warnings.push("Targa holds one alpha channel; with several, none was written (as in Photoshop)".into());
|
||||
}
|
||||
if img.layout().has_alpha() {
|
||||
return Ok(img);
|
||||
}
|
||||
map_bands(&img, ChannelLayout::Rgba, img.sample_type(), |vals| vals.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 1.0]).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 32-bit Targa's image: the colours (composited over white where the layers are transparent)
|
||||
/// and `channel`'s values as the alpha.
|
||||
fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, warnings: &mut Vec<String>) -> Result<Image, IoError> {
|
||||
let img = if img.layout().has_alpha() {
|
||||
warnings.push("transparency composited over white; the alpha channel is the Targa's alpha".into());
|
||||
matte_over_white(&img)?
|
||||
|
||||
+33
-2
@@ -63,7 +63,7 @@ use photocraft_doc::Document;
|
||||
use photocraft_psd::{PsdError, PsdFile};
|
||||
|
||||
pub use adjust_map::ADJUSTMENT_KEYS;
|
||||
pub use flat::{document_to_image, import_tiff_page, tga_alpha_channel_to_transparency};
|
||||
pub use flat::{document_to_image, import_tiff_page, tga_alpha_channel_to_transparency, tga_default_bits};
|
||||
pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with};
|
||||
pub use psd_import::{psd_to_document, psd_to_document_with};
|
||||
|
||||
@@ -162,11 +162,42 @@ pub struct ExportOptions {
|
||||
/// PSD/PSB always keep XMP; `.pcraft` keeps both. Everything by default, as Save As does;
|
||||
/// Export As offers None.
|
||||
pub xmp: XmpEmbed,
|
||||
/// Targa: bits per pixel of an RGB file. `None` writes 32 when the document has an alpha
|
||||
/// channel or transparency, else 24; Save As asks, starting at [`tga_default_bits`].
|
||||
pub tga_bits: Option<TgaBits>,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
fn default() -> Self {
|
||||
ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: false, xmp: XmpEmbed::All }
|
||||
ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: false, xmp: XmpEmbed::All, tga_bits: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bits per pixel of an RGB Targa (Photoshop's Targa Options › Resolution).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TgaBits {
|
||||
/// The colours alone, no alpha.
|
||||
Bits24,
|
||||
/// The colours and an 8-bit alpha: the document's alpha channel, or its transparency.
|
||||
Bits32,
|
||||
}
|
||||
|
||||
impl TgaBits {
|
||||
/// `24` or `32` as [`TgaBits`]; any other number is `None`.
|
||||
pub fn from_bits(bits: u64) -> Option<Self> {
|
||||
match bits {
|
||||
24 => Some(TgaBits::Bits24),
|
||||
32 => Some(TgaBits::Bits32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bits per pixel.
|
||||
pub fn bits(self) -> u8 {
|
||||
match self {
|
||||
TgaBits::Bits24 => 24,
|
||||
TgaBits::Bits32 => 32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+193
-3
@@ -612,15 +612,205 @@ fn tga_alpha_channel_wins_over_transparency_and_skips_spot_channels() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2124: Photoshop writes no channel as the alpha when there are several, and grayscale Targas
|
||||
/// have no alpha at all.
|
||||
/// #2124: Photoshop writes no channel as the alpha when there are several (measured again in
|
||||
/// #2482: the file is still 32-bit, fully opaque), and grayscale Targas have no alpha at all.
|
||||
#[test]
|
||||
fn tga_ignores_several_alpha_channels_and_grayscale() {
|
||||
let d = with_alpha_channel(with_alpha_channel(single(ColorMode::Rgb, SampleType::U8, false), "Alpha 1"), "Alpha 2");
|
||||
let r = export(&d, "a.tga", &ExportOptions::default()).unwrap();
|
||||
assert_eq!(tga(&r.bytes).0, 24);
|
||||
let (bpp, alpha_bits, img) = tga(&r.bytes);
|
||||
assert_eq!((bpp, alpha_bits), (32, 8));
|
||||
assert!(img.data().as_chunks::<4>().0.iter().all(|p| p[3] == 255), "no channel is the alpha");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("one alpha channel")), "{:?}", r.warnings);
|
||||
let d = with_alpha_channel(single(ColorMode::Grayscale, SampleType::U8, false), "Alpha 1");
|
||||
let r = export(&d, "a.tga", &ExportOptions::default()).unwrap();
|
||||
assert_eq!(tga(&r.bytes).0, 8);
|
||||
// Bits per pixel apply to RGB only: an explicit choice is reported, not applied.
|
||||
let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits32)).unwrap();
|
||||
assert_eq!(tga(&r.bytes).0, 8);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("grayscale")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
const TGA_W: u32 = 300;
|
||||
const TGA_H: u32 = 270;
|
||||
|
||||
fn tga_opts(bits: TgaBits) -> ExportOptions {
|
||||
ExportOptions { tga_bits: Some(bits), ..ExportOptions::default() }
|
||||
}
|
||||
|
||||
/// A `TGA_W`×`TGA_H` 8-bit RGB document (more than one tile each way) whose opaque Background
|
||||
/// reaches every byte value in each channel, and its colours as interleaved RGB bytes.
|
||||
fn tga_rgb_doc() -> (photocraft_doc::Document, Vec<u8>) {
|
||||
let mut d = photocraft_doc::Document::new("t", photocraft_geom::Size::new(TGA_W, TGA_H), ColorMode::Rgb, SampleType::U8);
|
||||
let (mut rgb, mut rgba) = (Vec::new(), Vec::new());
|
||||
for y in 0..TGA_H {
|
||||
for x in 0..TGA_W {
|
||||
let px = [((x + 3 * y) & 255) as u8, ((7 * x + y) & 255) as u8, ((x * y + 11) & 255) as u8];
|
||||
rgb.extend_from_slice(&px);
|
||||
rgba.extend_from_slice(&px);
|
||||
rgba.push(255);
|
||||
}
|
||||
}
|
||||
let s = photocraft_raster::Surface::from_interleaved(d.pixel_format(), d.bounds(), &rgba);
|
||||
let mut bg = photocraft_doc::Layer::new("Background", photocraft_doc::LayerContent::Raster(s));
|
||||
bg.locks.transparency = true;
|
||||
bg.locks.position = true;
|
||||
d.layers.push(bg);
|
||||
(d, rgb)
|
||||
}
|
||||
|
||||
/// `d` with an 8-bit "Alpha 1" channel holding `alpha` (one byte per pixel, row-major).
|
||||
fn with_alpha_bytes(mut d: photocraft_doc::Document, alpha: &[u8]) -> photocraft_doc::Document {
|
||||
let mut s = photocraft_raster::Surface::from_interleaved(photocraft_color::PixelFormat::GRAY8, d.bounds(), alpha);
|
||||
s.prune();
|
||||
d.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", s));
|
||||
d
|
||||
}
|
||||
|
||||
/// A left-to-right gradient through every alpha value, 0 to 255.
|
||||
fn gradient_alpha() -> Vec<u8> {
|
||||
(0..TGA_H).flat_map(|_| (0..TGA_W).map(|x| (x * 255 / (TGA_W - 1)) as u8)).collect()
|
||||
}
|
||||
|
||||
/// A decoded Targa's interleaved RGB and alpha bytes.
|
||||
fn rgb_and_alpha(img: &photocraft_codecs::Image) -> (Vec<u8>, Vec<u8>) {
|
||||
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba);
|
||||
let px = img.data().as_chunks::<4>().0;
|
||||
(px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), px.iter().map(|p| p[3]).collect())
|
||||
}
|
||||
|
||||
/// A reopened document's Background colours (interleaved RGB bytes) and its "Alpha 1" bytes.
|
||||
fn reopened(bytes: &[u8]) -> (Vec<u8>, Option<Vec<u8>>) {
|
||||
let d = import("a.tga", bytes).unwrap().document;
|
||||
let [bg] = &d.layers[..] else { panic!("one layer, got {}", d.layers.len()) };
|
||||
assert_eq!(bg.name, "Background");
|
||||
let px = bg.surface().unwrap().to_interleaved(d.bounds());
|
||||
assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 255), "the Background is opaque");
|
||||
let rgb = px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let alpha = match &d.channels[..] {
|
||||
[] => None,
|
||||
[ch] => {
|
||||
assert_eq!(ch.name, "Alpha 1");
|
||||
Some(ch.surface.to_interleaved(d.bounds()))
|
||||
}
|
||||
more => panic!("{} channels", more.len()),
|
||||
};
|
||||
(rgb, alpha)
|
||||
}
|
||||
|
||||
/// A 32-bit Targa holds the document's colours and its alpha channel exactly (straight, never
|
||||
/// premultiplied), and reopens as the same Background and "Alpha 1", byte for byte.
|
||||
#[test]
|
||||
fn tga_32_bit_round_trip_keeps_rgb_and_alpha_channel_byte_identical() {
|
||||
let (d, rgb) = tga_rgb_doc();
|
||||
let alpha = gradient_alpha();
|
||||
let d = with_alpha_bytes(d, &alpha);
|
||||
let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits32)).unwrap();
|
||||
let (bpp, alpha_bits, img) = tga(&r.bytes);
|
||||
assert_eq!((bpp, alpha_bits), (32, 8));
|
||||
let (file_rgb, file_alpha) = rgb_and_alpha(&img);
|
||||
assert!(file_rgb == rgb, "the file's colours are the document's");
|
||||
assert!(file_alpha == alpha, "the file's alpha is the alpha channel");
|
||||
let (back_rgb, back_alpha) = reopened(&r.bytes);
|
||||
assert!(back_rgb == rgb, "the reopened colours are the document's");
|
||||
assert!(back_alpha.as_deref() == Some(alpha.as_slice()), "the reopened Alpha 1 is the alpha channel");
|
||||
}
|
||||
|
||||
/// An all-black alpha channel (a texture's empty mask) is written as alpha 0 everywhere, and the
|
||||
/// colours under it are kept exactly: nothing is cleared or premultiplied.
|
||||
#[test]
|
||||
fn tga_32_bit_all_black_alpha_channel_keeps_every_colour() {
|
||||
let (d, rgb) = tga_rgb_doc();
|
||||
let black = vec![0u8; (TGA_W * TGA_H) as usize];
|
||||
let d = with_alpha_bytes(d, &black);
|
||||
for opts in [tga_opts(TgaBits::Bits32), ExportOptions::default()] {
|
||||
let r = export(&d, "a.tga", &opts).unwrap();
|
||||
let (bpp, alpha_bits, img) = tga(&r.bytes);
|
||||
assert_eq!((bpp, alpha_bits), (32, 8), "{:?}", opts.tga_bits);
|
||||
let (file_rgb, file_alpha) = rgb_and_alpha(&img);
|
||||
assert!(file_alpha.iter().all(|&a| a == 0), "alpha 0 everywhere");
|
||||
assert!(file_rgb == rgb, "{:?}: the colours under alpha 0 are unchanged", opts.tga_bits);
|
||||
let (back_rgb, back_alpha) = reopened(&r.bytes);
|
||||
assert!(back_rgb == rgb, "the reopened colours are unchanged");
|
||||
assert!(back_alpha.as_deref() == Some(black.as_slice()), "Alpha 1 reopens black");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 24-bit Targa has no alpha: the alpha channel is left out (and named in a warning), the
|
||||
/// colours are kept exactly, and the file reopens without a channel.
|
||||
#[test]
|
||||
fn tga_24_bit_writes_no_alpha() {
|
||||
let (d, rgb) = tga_rgb_doc();
|
||||
let d = with_alpha_bytes(d, &gradient_alpha());
|
||||
let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits24)).unwrap();
|
||||
let (bpp, alpha_bits, img) = tga(&r.bytes);
|
||||
assert_eq!((bpp, alpha_bits), (24, 0));
|
||||
assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgb);
|
||||
assert!(img.data() == rgb.as_slice(), "the colours are the document's");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("24-bit") && w.contains("\"Alpha 1\"")), "{:?}", r.warnings);
|
||||
let (back_rgb, back_alpha) = reopened(&r.bytes);
|
||||
assert!(back_rgb == rgb);
|
||||
assert_eq!(back_alpha, None, "no alpha channel");
|
||||
}
|
||||
|
||||
/// A 24-bit Targa of a transparent layer is the layer composited over white.
|
||||
#[test]
|
||||
fn tga_24_bit_composites_transparency_over_white() {
|
||||
let d = single(ColorMode::Rgb, SampleType::U8, true);
|
||||
let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits24)).unwrap();
|
||||
let (bpp, alpha_bits, img) = tga(&r.bytes);
|
||||
assert_eq!((bpp, alpha_bits), (24, 0));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("composited over white")), "{:?}", r.warnings);
|
||||
let src = d.layers[0].surface().unwrap().read_region(d.bounds());
|
||||
for (i, (px, s)) in img.data().as_chunks::<3>().0.iter().zip(src.as_chunks::<4>().0.iter()).enumerate() {
|
||||
let over_white = s[0] * s[3] + 1.0 - s[3];
|
||||
assert!((f32::from(px[0]) / 255.0 - over_white).abs() <= 1.0 / 255.0, "red {i}: {} vs {over_white}", px[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 32 bits without an alpha channel: the layer's transparency is the alpha (as before Targa
|
||||
/// Options), and an opaque image gets a fully opaque alpha, its colours unchanged.
|
||||
#[test]
|
||||
fn tga_32_bit_without_alpha_channel_writes_transparency_or_opaque() {
|
||||
let (d, rgb) = tga_rgb_doc();
|
||||
let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits32)).unwrap();
|
||||
let (bpp, alpha_bits, img) = tga(&r.bytes);
|
||||
assert_eq!((bpp, alpha_bits), (32, 8));
|
||||
let (file_rgb, file_alpha) = rgb_and_alpha(&img);
|
||||
assert!(file_rgb == rgb);
|
||||
assert!(file_alpha.iter().all(|&a| a == 255));
|
||||
// Without Targa Options an opaque document stays 24-bit.
|
||||
assert_eq!(tga(&export(&d, "a.tga", &ExportOptions::default()).unwrap().bytes).0, 24);
|
||||
|
||||
let d = single(ColorMode::Rgb, SampleType::U8, true);
|
||||
let r = export(&d, "a.tga", &tga_opts(TgaBits::Bits32)).unwrap();
|
||||
let (_, file_alpha) = rgb_and_alpha(&tga(&r.bytes).2);
|
||||
let src = d.layers[0].surface().unwrap().to_interleaved(d.bounds());
|
||||
let src_alpha: Vec<u8> = src.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
assert!(file_alpha == src_alpha, "the transparency is the alpha");
|
||||
}
|
||||
|
||||
/// 16-bit documents are written as 8-bit Targas at either depth.
|
||||
#[test]
|
||||
fn tga_bits_apply_to_16_bit_documents() {
|
||||
let d = with_alpha_channel(single(ColorMode::Rgb, SampleType::U16, false), "Alpha 1");
|
||||
for (bits, want) in [(TgaBits::Bits24, (24, 0)), (TgaBits::Bits32, (32, 8))] {
|
||||
let r = export(&d, "a.tga", &tga_opts(bits)).unwrap();
|
||||
let (bpp, alpha_bits, _) = tga(&r.bytes);
|
||||
assert_eq!((bpp, alpha_bits), want, "{bits:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Targa Options starts at 32 bits with an alpha channel (spot channels don't count), else 24.
|
||||
#[test]
|
||||
fn tga_default_bits_follow_the_alpha_channels() {
|
||||
let d = single(ColorMode::Rgb, SampleType::U8, true);
|
||||
assert_eq!(tga_default_bits(&d), TgaBits::Bits24, "transparency alone");
|
||||
let mut spot = d.clone();
|
||||
let s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
spot.channels
|
||||
.push(photocraft_doc::AlphaChannel { spot: Some((photocraft_color::Color::rgb(1.0, 0.0, 0.0), 1.0)), ..photocraft_doc::AlphaChannel::new("Spot", s) });
|
||||
assert_eq!(tga_default_bits(&spot), TgaBits::Bits24, "spot channel");
|
||||
assert_eq!(tga_default_bits(&with_alpha_channel(d, "Alpha 1")), TgaBits::Bits32);
|
||||
assert_eq!((TgaBits::from_bits(24), TgaBits::from_bits(32), TgaBits::from_bits(16)), (Some(TgaBits::Bits24), Some(TgaBits::Bits32), None));
|
||||
}
|
||||
@@ -53,6 +53,7 @@ fn main() {
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_quality = q;
|
||||
}
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| photocraft_format::atomic_write(std::path::Path::new(path), bytes).map_err(|e| e.to_string()))),
|
||||
|
||||
@@ -1100,7 +1100,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
}
|
||||
None => err("missing `path`"),
|
||||
},
|
||||
"app.save" => run_waiting(app, p.get("wait").and_then(Value::as_bool).unwrap_or(true), |app| app.save_automation(s("path").map(str::to_string))),
|
||||
"app.save" => match crate::targa_options_ui::bits_param(p, "app.save") {
|
||||
Ok(tga_bits) => {
|
||||
let path = s("path").map(str::to_string);
|
||||
run_waiting(app, p.get("wait").and_then(Value::as_bool).unwrap_or(true), move |app| app.save_automation_with(path, tga_bits))
|
||||
}
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"app.quit" => {
|
||||
app.allow_close = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -1902,6 +1908,41 @@ mod tests {
|
||||
assert_eq!(error, "filesystem command denied");
|
||||
}
|
||||
|
||||
/// `app.save` takes the Targa bits per pixel (`tgaBits`) and never stops at Targa Options.
|
||||
#[test]
|
||||
fn automation_save_passes_targa_bits_without_asking() {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
// The bits each export was asked for, and each path written.
|
||||
let asked: Rc<RefCell<Vec<Option<u8>>>> = 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.tga_bits.map(photocraft_io::TgaBits::bits));
|
||||
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();
|
||||
for params in [json!({"path": "a.tga"}), json!({"path": "b.tga", "tgaBits": 24}), json!({"path": "c.tga", "tgaBits": 32})] {
|
||||
let r = call(&mut app, &ctx, "app.save", params.clone());
|
||||
assert_eq!(r["result"]["path"], params["path"], "{r}");
|
||||
assert!(app.targa_options.is_none(), "automation saves never prompt");
|
||||
}
|
||||
assert_eq!(*asked.borrow(), [None, Some(24), Some(32)]);
|
||||
assert_eq!(*written.borrow(), ["a.tga", "b.tga", "c.tga"]);
|
||||
let r = call(&mut app, &ctx, "app.save", json!({"path": "d.tga", "tgaBits": 16}));
|
||||
assert!(r["error"].as_str().unwrap().contains("tgaBits"), "{r}");
|
||||
assert_eq!(written.borrow().len(), 3, "a bad tgaBits writes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_open_and_save_use_the_roots_and_reply_with_warnings() {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
|
||||
@@ -192,7 +192,7 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool {
|
||||
return false;
|
||||
}
|
||||
match saved {
|
||||
Ok(_) => app.tiff_options.is_none() && !app.saving(),
|
||||
Ok(_) => !app.save_options_open() && !app.saving(),
|
||||
// Backing out of the file dialog is the user's choice, not an error.
|
||||
Err(e) if e == crate::file_dialog::CANCELLED => false,
|
||||
Err(e) => {
|
||||
@@ -208,7 +208,7 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool {
|
||||
/// Copies and failed writes leave the doc dirty.
|
||||
pub(crate) fn saved_document(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) {
|
||||
// Still the prompt that asked (quitting may have replaced it meanwhile).
|
||||
if app.tiff_options.is_none()
|
||||
if !app.save_options_open()
|
||||
&& app.discard.as_ref().and_then(|p| p.docs.first()) == Some(&doc)
|
||||
&& index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty())
|
||||
{
|
||||
@@ -222,9 +222,9 @@ fn couldnt_save(e: String) -> String {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
// Save can ask for a path and then TIFF Options, and then write in the background. Wait for
|
||||
// all three; cancellation or a failed save brings us back.
|
||||
if app.file_dialog_open() || app.tiff_options.is_some() || app.saving() {
|
||||
// Save can ask for a path and then TIFF or Targa Options, and then write in the background.
|
||||
// Wait for all three; cancellation or a failed save brings us back.
|
||||
if app.file_dialog_open() || app.save_options_open() || app.saving() {
|
||||
return;
|
||||
}
|
||||
let Some(p) = &app.discard else { return };
|
||||
@@ -728,6 +728,9 @@ mod tests {
|
||||
#[path = "tiff_tests.rs"]
|
||||
mod tiff_tests;
|
||||
|
||||
#[path = "targa_tests.rs"]
|
||||
mod targa_tests;
|
||||
|
||||
/// One frame with the window's close button pressed; the commands the guard sent.
|
||||
fn window_close_commands(app: &mut PhotocraftApp) -> Vec<egui::ViewportCommand> {
|
||||
let mut info = egui::ViewportInfo::default();
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Targa Options on Save As and Save a Copy: the bits per pixel it asks for, the scripted
|
||||
//! `tgaBits` that skips it, and the close prompt waiting behind it.
|
||||
use super::*;
|
||||
use crate::{FileDialogAnswer, targa_options_ui};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_io::TgaBits;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
/// Each write: the path, and the Targa bits the export was asked for (as JSON).
|
||||
type Writes = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// The JSON a write records for `bits`.
|
||||
fn asked(bits: Option<u8>) -> Vec<u8> {
|
||||
serde_json::to_vec(&bits).unwrap()
|
||||
}
|
||||
|
||||
/// One 8×8 RGB document (with an "Alpha 1" channel when `alpha_channel`) whose exports record
|
||||
/// their Targa bits, in a harness showing Targa Options and answering a save dialog with `to`.
|
||||
fn targa_app(alpha_channel: bool, to: &str) -> (Prompted, Writes) {
|
||||
let mut app = app_with_docs(1);
|
||||
if alpha_channel {
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("select.saveSelection", json!({})).unwrap();
|
||||
}
|
||||
let writes: Writes = Rc::default();
|
||||
let written = writes.clone();
|
||||
app.services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(to.into()))]).0);
|
||||
app.services.export = Some(Box::new(|_, _, settings| Ok((asked(settings.tga_bits.map(TgaBits::bits)), Vec::new()))));
|
||||
app.services.write = Some(Box::new(move |path, bytes| {
|
||||
written.borrow_mut().push((path.to_string(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}));
|
||||
let h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(
|
||||
|ui, app| {
|
||||
show(app, ui.ctx());
|
||||
targa_options_ui::show(app, ui.ctx());
|
||||
app.poll_file_dialog(ui.ctx(), None);
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.ctx.set_os(egui::os::OperatingSystem::Windows);
|
||||
(h, writes)
|
||||
}
|
||||
|
||||
/// Save As to a Targa asks first: 32 bits per pixel when the document has an alpha channel,
|
||||
/// else 24 (as Photoshop's Targa Options starts), and writes only once answered.
|
||||
#[test]
|
||||
fn save_as_to_a_targa_asks_for_its_bits_per_pixel() {
|
||||
for (alpha_channel, start) in [(false, TgaBits::Bits24), (true, TgaBits::Bits32)] {
|
||||
let (mut h, writes) = targa_app(alpha_channel, "unused.tga");
|
||||
h.state_mut().save_as(Some("t.tga".into())).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().targa_options.as_ref().map(|p| (p.bits, p.copy)), Some((start, false)));
|
||||
assert!(writes.borrow().is_empty(), "nothing is written before the answer");
|
||||
assert!(h.state_mut().save_as(Some("other.tga".into())).is_err(), "one save at a time");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(writes.borrow().as_slice(), &[("t.tga".to_string(), asked(Some(start.bits())))]);
|
||||
assert!(h.state().targa_options.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("t.tga"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The choice made in the prompt is the one written; Cancel writes nothing.
|
||||
#[test]
|
||||
fn targa_options_writes_the_choice_or_cancels() {
|
||||
let (mut h, writes) = targa_app(true, "unused.tga");
|
||||
h.state_mut().save_as(Some("t.tga".into())).unwrap();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("24 bits/pixel").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(writes.borrow().as_slice(), &[("t.tga".to_string(), asked(Some(24)))]);
|
||||
|
||||
h.state_mut().save_as(Some("c.tga".into())).unwrap();
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().targa_options.is_none());
|
||||
assert_eq!(writes.borrow().len(), 1, "cancelled: nothing more written");
|
||||
assert_eq!(h.state().ui.status, "Save cancelled");
|
||||
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some("t.tga"));
|
||||
}
|
||||
|
||||
/// Scripts choose with `tgaBits` and are never asked; a grayscale Targa (8-bit, nothing to choose)
|
||||
/// and other formats aren't asked either.
|
||||
#[test]
|
||||
fn scripted_bits_grayscale_and_other_formats_skip_the_prompt() {
|
||||
let (mut h, writes) = targa_app(true, "unused.tga");
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.tga", "tgaBits": 24})).unwrap();
|
||||
assert!(h.state().targa_options.is_none());
|
||||
assert_eq!(writes.borrow().as_slice(), &[("s.tga".to_string(), asked(Some(24)))]);
|
||||
for bad in [json!(16), json!("32")] {
|
||||
assert!(crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.tga", "tgaBits": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(writes.borrow().len(), 1, "a bad tgaBits writes nothing");
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "p.png"})).unwrap();
|
||||
h.state_mut().run("image.mode.grayscale", json!({})).unwrap();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "g.tga"})).unwrap();
|
||||
assert!(h.state().targa_options.is_none());
|
||||
assert_eq!(writes.borrow()[1..], [("p.png".to_string(), asked(None)), ("g.tga".to_string(), asked(None))]);
|
||||
}
|
||||
|
||||
/// Save a Copy to a Targa asks too, and the copy leaves the document's path and unsaved state.
|
||||
#[test]
|
||||
fn save_a_copy_to_a_targa_asks_and_keeps_the_document_unsaved() {
|
||||
let (mut h, writes) = targa_app(true, "copy.tga");
|
||||
make_dirty(h.state_mut(), 0);
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().targa_options.as_ref().map(|p| (p.bits, p.copy)), Some((TgaBits::Bits32, true)));
|
||||
assert!(targa_options_ui::park(h.state_mut(), "other.tga".into(), false, true).is_err(), "the waiting prompt is kept");
|
||||
assert_eq!(h.state().targa_options.as_ref().map(|p| p.path.as_str()), Some("copy.tga"));
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(writes.borrow().as_slice(), &[("copy.tga".to_string(), asked(Some(32)))]);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(st.is_dirty());
|
||||
assert_eq!(st.path, None);
|
||||
}
|
||||
|
||||
/// Save As of a document a flat Targa can't hold (here, two layers) is a copy through Targa
|
||||
/// Options too, as without it: the document keeps its path and its unsaved changes (#2550).
|
||||
#[test]
|
||||
fn targa_options_keeps_a_layered_save_as_a_copy() {
|
||||
let (mut h, writes) = targa_app(false, "unused.tga");
|
||||
make_dirty(h.state_mut(), 0);
|
||||
h.state_mut().save_as(Some("t.tga".into())).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().targa_options.as_ref().map(|p| p.copy), Some(true));
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(writes.borrow().as_slice(), &[("t.tga".to_string(), asked(Some(24)))]);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(st.is_dirty());
|
||||
assert_eq!(st.path, None);
|
||||
}
|
||||
|
||||
/// An action being recorded keeps a Targa Save As with its bits, answered in Targa Options or
|
||||
/// scripted (#2032), and plays it back without asking; the dialog's Save a Copy isn't recorded.
|
||||
#[test]
|
||||
fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() {
|
||||
let (mut h, writes) = targa_app(true, "copy.tga");
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().run("actions.record", json!({})).unwrap();
|
||||
h.state_mut().save_as(Some("t.tga".into())).unwrap();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("24 bits/pixel").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "file.saveAs", json!({"path": "s.tga", "tgaBits": 32})).unwrap();
|
||||
h.state_mut().run("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
writes.borrow().as_slice(),
|
||||
&[("t.tga".to_string(), asked(Some(24))), ("copy.tga".to_string(), asked(Some(32))), ("s.tga".to_string(), asked(Some(32)))]
|
||||
);
|
||||
let steps = &h.state().session.actions.list[0].steps;
|
||||
assert_eq!(steps, &[("file.saveAs".into(), json!({"path": "t.tga", "tgaBits": 24})), ("file.saveAs".into(), json!({"path": "s.tga", "tgaBits": 32}))]);
|
||||
writes.borrow_mut().clear();
|
||||
let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
assert!(h.state().targa_options.is_none(), "playback doesn't ask");
|
||||
assert_eq!(writes.borrow().as_slice(), &[("t.tga".to_string(), asked(Some(24))), ("s.tga".to_string(), asked(Some(32)))]);
|
||||
}
|
||||
|
||||
/// Closing an unsaved document whose save goes to a Targa waits behind Targa Options, then closes.
|
||||
#[test]
|
||||
fn closing_after_a_targa_save_waits_for_targa_options() {
|
||||
let (mut h, writes) = targa_app(false, "kept.tga");
|
||||
// An edit that keeps the lone Background, so the Targa holds the document and isn't a copy.
|
||||
h.state_mut().run("edit.fill", json!({"color": "#000000"})).unwrap();
|
||||
assert!(intercept(h.state_mut(), "file.close", &Value::Null));
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Y);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().targa_options.is_some());
|
||||
assert_eq!(h.state().session.documents().len(), 1, "nothing closes before Targa Options saves");
|
||||
assert!(h.query_by_label("(Y)es").is_none(), "the close prompt waits behind Targa Options");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(writes.borrow().as_slice(), &[("kept.tga".to_string(), asked(Some(24)))]);
|
||||
assert!(h.state().discard.is_none());
|
||||
assert!(h.state().session.documents().is_empty());
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
|
||||
f.insert("transparency".into(), json!(true));
|
||||
f.insert("scale".into(), json!(100));
|
||||
f.insert("metadata".into(), json!("none"));
|
||||
f.insert("tgaBits".into(), json!("auto"));
|
||||
f.insert("__w".into(), json!(st.doc.size.width));
|
||||
f.insert("__h".into(), json!(st.doc.size.height));
|
||||
Ok(app.ui.open_dialog(DialogKind::Command, f))
|
||||
@@ -126,6 +127,12 @@ fn lossless(f: &Map<String, Value>) -> bool {
|
||||
f.get("lossless").and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// TGA bits per pixel: `"24"` or `"32"`; `"auto"` (the default) is 32 with an alpha channel or
|
||||
/// transparency, else 24, as Export As wrote before it offered the choice.
|
||||
fn tga_bits(f: &Map<String, Value>) -> Option<photocraft_io::TgaBits> {
|
||||
s(f, "tgaBits").parse().ok().and_then(photocraft_io::TgaBits::from_bits)
|
||||
}
|
||||
|
||||
fn settings(f: &Map<String, Value>) -> ExportSettings {
|
||||
let fmt = s_fmt(f);
|
||||
let quality = n(f, "quality", 85.0).clamp(1.0, 100.0) as u8;
|
||||
@@ -134,6 +141,7 @@ fn settings(f: &Map<String, Value>) -> ExportSettings {
|
||||
webp_lossless: fmt != "webp" || lossless(f),
|
||||
webp_quality: (fmt == "webp" && !lossless(f)).then_some(quality),
|
||||
xmp_all: s(f, "metadata") == "all",
|
||||
tga_bits: if fmt == "tga" { tga_bits(f) } else { None },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -176,6 +184,17 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
crate::widgets::slider_row(ui, tl!("Quality"), &mut q, 1.0..=100.0, "%", None);
|
||||
f.insert("quality".into(), json!(q.round()));
|
||||
}
|
||||
if fmt == "tga" {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Resolution")).color(t.text_dim));
|
||||
let mut bits = s(f, "tgaBits");
|
||||
let opts: Vec<(String, &str)> =
|
||||
vec![("auto".into(), tl!("Auto")), ("24".into(), tl!("24 bits/pixel")), ("32".into(), tl!("32 bits/pixel"))];
|
||||
if crate::widgets::dropdown(ui, "export-tga-bits", &mut bits, &opts, 130.0) {
|
||||
f.insert("tgaBits".into(), json!(bits));
|
||||
}
|
||||
});
|
||||
}
|
||||
if fmt != "jpg" {
|
||||
let mut tr = f.get("transparency").and_then(Value::as_bool).unwrap_or(true);
|
||||
crate::widgets::checkbox(ui, &mut tr, tl!("Transparency"));
|
||||
@@ -207,13 +226,14 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
let layer = f.get("__layer").and_then(Value::as_u64);
|
||||
let key = egui::Id::new(("export-preview", doc.id.0, app.session.active().map_or(0, |s| s.revision), layer));
|
||||
let sig = format!(
|
||||
"{}{}{}{}{}{}",
|
||||
"{}{}{}{}{}{}{}",
|
||||
s_fmt(f),
|
||||
n(f, "quality", 85.0),
|
||||
lossless(f),
|
||||
f.get("transparency").map(|v| v.to_string()).unwrap_or_default(),
|
||||
n(f, "scale", 100.0),
|
||||
s(f, "metadata")
|
||||
s(f, "metadata"),
|
||||
s(f, "tgaBits")
|
||||
);
|
||||
let cached: Option<(String, Option<u64>, Arc<egui::TextureHandle>)> = ui.data(|d| d.get_temp(key));
|
||||
let (size, tex) = match cached.filter(|c| c.0 == sig) {
|
||||
@@ -430,4 +450,20 @@ mod tests {
|
||||
f.insert("format".into(), json!("png"));
|
||||
assert!(settings(&f).webp_lossless, "other formats leave the WebP default alone");
|
||||
}
|
||||
|
||||
/// TGA's Resolution (bits per pixel): Auto leaves the choice to the export; 24 and 32 force it, and only
|
||||
/// for TGA.
|
||||
#[test]
|
||||
fn tga_settings_follow_the_bits_per_pixel() {
|
||||
use photocraft_io::TgaBits;
|
||||
let mut f = Map::new();
|
||||
f.insert("format".into(), json!("tga"));
|
||||
assert_eq!(settings(&f).tga_bits, None, "no choice made");
|
||||
for (v, want) in [("auto", None), ("24", Some(TgaBits::Bits24)), ("32", Some(TgaBits::Bits32)), ("16", None)] {
|
||||
f.insert("tgaBits".into(), json!(v));
|
||||
assert_eq!(settings(&f).tga_bits, want, "{v}");
|
||||
}
|
||||
f.insert("format".into(), json!("png"));
|
||||
assert_eq!(settings(&f).tga_bits, None, "only TGA has bits per pixel");
|
||||
}
|
||||
}
|
||||
@@ -2195,6 +2195,9 @@ cameraRaw Color Barvy
|
||||
Structure: Struktura:
|
||||
Content-Aware Move Tool Nástroj přesun s ohledem na obsah
|
||||
TIFF Options Možnosti TIFF
|
||||
Targa Options Možnosti Targa
|
||||
24 bits/pixel 24 bitů/pixel
|
||||
32 bits/pixel 32 bitů/pixel
|
||||
Including layers will increase file size. Zahrnutí vrstev zvětší velikost souboru.
|
||||
Discard Layers and Save a Copy Zahodit vrstvy a uložit kopii
|
||||
Don't show again Příště nezobrazovat
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2228,6 +2228,9 @@
|
||||
Structure: Struktur:
|
||||
Content-Aware Move Tool Inhaltsbasiert-verschieben-Werkzeug
|
||||
TIFF Options TIFF-Optionen
|
||||
Targa Options Targa-Optionen
|
||||
24 bits/pixel 24 Bit/Pixel
|
||||
32 bits/pixel 32 Bit/Pixel
|
||||
Including layers will increase file size. Das Einbeziehen von Ebenen vergrößert die Datei.
|
||||
Discard Layers and Save a Copy Ebenen verwerfen und Kopie speichern
|
||||
Don't show again Nicht mehr anzeigen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1060,6 +1060,9 @@
|
||||
Enable {name} Ενεργοποίηση: {name}
|
||||
Hold Alt while drawing for straight segments Κρατήστε πατημένο το Alt κατά τη σχεδίαση για ευθύγραμμα τμήματα
|
||||
TIFF Options Επιλογές TIFF
|
||||
Targa Options Επιλογές Targa
|
||||
24 bits/pixel 24 bit/εικονοστοιχείο
|
||||
32 bits/pixel 32 bit/εικονοστοιχείο
|
||||
Including layers will increase file size. Η συμπερίληψη επιπέδων θα αυξήσει το μέγεθος του αρχείου.
|
||||
Discard Layers and Save a Copy Απόρριψη επιπέδων και αποθήκευση αντιγράφου
|
||||
Don't show again Να μην εμφανιστεί ξανά
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 973 and column 18.
|
@@ -2264,6 +2264,9 @@ cameraRaw Amount Cantidad
|
||||
Structure: Estructura:
|
||||
Content-Aware Move Tool Herramienta Mover según el contenido
|
||||
TIFF Options Opciones de TIFF
|
||||
Targa Options Opciones de Targa
|
||||
24 bits/pixel 24 bits/píxel
|
||||
32 bits/pixel 32 bits/píxel
|
||||
Including layers will increase file size. Incluir las capas aumentará el tamaño del archivo.
|
||||
Discard Layers and Save a Copy Descartar capas y guardar una copia
|
||||
Don't show again No volver a mostrar
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2618,6 +2618,9 @@ cameraRaw Blending Fusion
|
||||
Structure: Structure :
|
||||
Content-Aware Move Tool Outil Déplacement selon le contenu
|
||||
TIFF Options Options TIFF
|
||||
Targa Options Options Targa
|
||||
24 bits/pixel 24 bits/pixel
|
||||
32 bits/pixel 32 bits/pixel
|
||||
Including layers will increase file size. L'inclusion des calques augmentera la taille du fichier.
|
||||
Discard Layers and Save a Copy Supprimer les calques et enregistrer une copie
|
||||
Don't show again Ne plus afficher
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2605,6 +2605,9 @@ cameraRaw Blending Pembauran
|
||||
Structure: Struktur:
|
||||
Content-Aware Move Tool Alat Content-Aware Move
|
||||
TIFF Options Opsi TIFF
|
||||
Targa Options Opsi Targa
|
||||
24 bits/pixel 24 Bit/Piksel
|
||||
32 bits/pixel 32 Bit/Piksel
|
||||
Including layers will increase file size. Menyertakan layer akan memperbesar ukuran file.
|
||||
Discard Layers and Save a Copy Buang Layer dan Simpan Salinan
|
||||
Don't show again Jangan tampilkan lagi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2260,6 +2260,9 @@ cameraRaw Amount Quantità
|
||||
Hold Alt while drawing for straight segments Tieni premuto Alt mentre disegni per segmenti dritti
|
||||
Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Trascina o fai clic sui punti del poligono · tieni premuto Alt durante il lazo per segmenti dritti · {add} aggiunge · {sub} prima di disegnare sottrae
|
||||
TIFF Options Opzioni TIFF
|
||||
Targa Options Opzioni Targa
|
||||
24 bits/pixel 24 bit/pixel
|
||||
32 bits/pixel 32 bit/pixel
|
||||
Including layers will increase file size. Includere i livelli aumenterà le dimensioni del file.
|
||||
Discard Layers and Save a Copy Elimina i livelli e salva una copia
|
||||
Don't show again Non mostrare più
|
||||
|
||||
|
@@ -2171,6 +2171,9 @@ cameraRaw Color カラー
|
||||
Structure: 構造:
|
||||
Content-Aware Move Tool コンテンツに応じた移動ツール
|
||||
TIFF Options TIFF オプション
|
||||
Targa Options Targa オプション
|
||||
24 bits/pixel 24 ビット/ピクセル
|
||||
32 bits/pixel 32 ビット/ピクセル
|
||||
Including layers will increase file size. レイヤーを含めるとファイルサイズが大きくなります。
|
||||
Discard Layers and Save a Copy レイヤーを破棄してコピーを保存
|
||||
Don't show again 次回から表示しない
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2783,6 +2783,9 @@ cameraRaw Color 색상
|
||||
Structure: 구조:
|
||||
Content-Aware Move Tool 내용 인식 이동 도구
|
||||
TIFF Options TIFF 옵션
|
||||
Targa Options Targa 옵션
|
||||
24 bits/pixel 24비트/픽셀
|
||||
32 bits/pixel 32비트/픽셀
|
||||
Including layers will increase file size. 레이어를 포함하면 파일 크기가 커집니다.
|
||||
Discard Layers and Save a Copy 레이어를 삭제하고 사본 저장
|
||||
Don't show again 다시 표시 안 함
|
||||
|
||||
|
@@ -2213,6 +2213,9 @@
|
||||
Structure: Structuur:
|
||||
Content-Aware Move Tool Inhoudsbewust verplaatsen
|
||||
TIFF Options TIFF-opties
|
||||
Targa Options Targa-opties
|
||||
24 bits/pixel 24 bits/pixel
|
||||
32 bits/pixel 32 bits/pixel
|
||||
Including layers will increase file size. Lagen meenemen vergroot de bestandsgrootte.
|
||||
Discard Layers and Save a Copy Lagen negeren en kopie opslaan
|
||||
Don't show again Niet meer tonen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2641,6 +2641,9 @@ cameraRaw Roughness Chropowatość
|
||||
Structure: Struktura:
|
||||
Content-Aware Move Tool Narzędzie Przesunięcie z uwzględnieniem zawartości
|
||||
TIFF Options Opcje TIFF
|
||||
Targa Options Opcje Targa
|
||||
24 bits/pixel 24 bity/piksel
|
||||
32 bits/pixel 32 bity/piksel
|
||||
Including layers will increase file size. Dołączenie warstw zwiększy rozmiar pliku.
|
||||
Discard Layers and Save a Copy Odrzuć warstwy i zapisz kopię
|
||||
Don't show again Nie pokazuj ponownie
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2621,6 +2621,9 @@ cameraRaw Whites Brancos
|
||||
Structure: Estrutura:
|
||||
Content-Aware Move Tool Ferramenta Mover com reconhecimento de conteúdo
|
||||
TIFF Options Opções de TIFF
|
||||
Targa Options Opções de Targa
|
||||
24 bits/pixel 24 bits/pixel
|
||||
32 bits/pixel 32 bits/pixel
|
||||
Including layers will increase file size. Incluir as camadas aumentará o tamanho do arquivo.
|
||||
Discard Layers and Save a Copy Descartar camadas e salvar uma cópia
|
||||
Don't show again Não mostrar novamente
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2467,6 +2467,9 @@ cameraRaw Roughness Неровность
|
||||
Structure: Структура:
|
||||
Content-Aware Move Tool Перемещение с учётом содержимого
|
||||
TIFF Options Параметры TIFF
|
||||
Targa Options Параметры Targa
|
||||
24 bits/pixel 24 бит/пиксел
|
||||
32 bits/pixel 32 бит/пиксел
|
||||
Including layers will increase file size. Включение слоёв увеличит размер файла.
|
||||
Discard Layers and Save a Copy Удалить слои и сохранить копию
|
||||
Don't show again Больше не показывать
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2441,6 +2441,9 @@ cameraRaw Roughness Шорсткість
|
||||
Structure: Структура:
|
||||
Content-Aware Move Tool Інструмент «Переміщення з урахуванням вмісту»
|
||||
TIFF Options Параметри TIFF
|
||||
Targa Options Параметри Targa
|
||||
24 bits/pixel 24 біт/піксель
|
||||
32 bits/pixel 32 біт/піксель
|
||||
Including layers will increase file size. Додавання шарів збільшить розмір файлу.
|
||||
Discard Layers and Save a Copy Відкинути шари та зберегти копію
|
||||
Don't show again Більше не показувати
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1330 and column 18.
|
@@ -2203,6 +2203,9 @@ cameraRaw Blending 混合
|
||||
Structure: 结构:
|
||||
Content-Aware Move Tool 内容感知移动工具
|
||||
TIFF Options TIFF 选项
|
||||
Targa Options Targa 选项
|
||||
24 bits/pixel 24 位/像素
|
||||
32 bits/pixel 32 位/像素
|
||||
Including layers will increase file size. 包含图层会增加文件大小。
|
||||
Discard Layers and Save a Copy 扔掉图层并存储副本
|
||||
Don't show again 不再显示
|
||||
|
||||
|
@@ -2173,6 +2173,9 @@ cameraRaw Blending 混合
|
||||
Structure: 結構:
|
||||
Content-Aware Move Tool 內容感知移動工具
|
||||
TIFF Options TIFF 選項
|
||||
Targa Options Targa 選項
|
||||
24 bits/pixel 24 位元/像素
|
||||
32 bits/pixel 32 位元/像素
|
||||
Including layers will increase file size. 包含圖層會增加檔案大小。
|
||||
Discard Layers and Save a Copy 放棄圖層並儲存拷貝
|
||||
Don't show again 不再顯示
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -693,3 +693,16 @@ fn escape_with_the_palette_open_does_not_deselect() {
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to the palette");
|
||||
}
|
||||
|
||||
/// Targa Options owns Esc (it cancels the save), so the selection stays.
|
||||
#[test]
|
||||
fn escape_with_targa_options_open_does_not_deselect() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("select.all", json!({})).unwrap();
|
||||
h.state_mut().save_as(Some("t.tga".into())).unwrap();
|
||||
assert!(h.state().targa_options.is_some());
|
||||
h.run_steps(1);
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to Targa Options");
|
||||
}
|
||||
+70
-26
@@ -158,6 +158,7 @@ pub mod stylus;
|
||||
pub mod swatches_ui;
|
||||
pub mod symmetry_ui;
|
||||
mod tab_strip;
|
||||
pub mod targa_options_ui;
|
||||
pub mod theme;
|
||||
pub mod tiff_options_ui;
|
||||
mod timeline_ui;
|
||||
@@ -212,11 +213,14 @@ pub struct ExportSettings {
|
||||
/// Export As starts at Metadata: None, since the packet can carry the text of every type
|
||||
/// layer and one id per placed document (#647).
|
||||
pub xmp_all: bool,
|
||||
/// Targa: bits per pixel from Targa Options or Export As (`None`: 32 with an alpha channel or
|
||||
/// transparency, else 24).
|
||||
pub tga_bits: Option<photocraft_io::TgaBits>,
|
||||
}
|
||||
|
||||
impl Default for ExportSettings {
|
||||
fn default() -> Self {
|
||||
ExportSettings { jpeg_quality: None, webp_lossless: true, webp_quality: None, tiff_layers: true, xmp_all: true }
|
||||
ExportSettings { jpeg_quality: None, webp_lossless: true, webp_quality: None, tiff_layers: true, xmp_all: true, tga_bits: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,6 +628,8 @@ pub struct PhotocraftApp {
|
||||
/// Where each document was last saved or exported to through a dialog (#1826): its next Save
|
||||
/// As or export dialog starts there rather than beside the document. Session-only.
|
||||
pub(crate) save_dirs: HashMap<photocraft_doc::DocId, std::path::PathBuf>,
|
||||
/// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`).
|
||||
pub(crate) targa_options: Option<targa_options_ui::Prompt>,
|
||||
/// Set once the user has agreed to quit, so the resulting close request goes through.
|
||||
pub(crate) allow_close: bool,
|
||||
/// Pen pressure/tilt from the platform (see `stylus`).
|
||||
@@ -740,6 +746,7 @@ impl PhotocraftApp {
|
||||
file_dialog: None,
|
||||
tiff_options: None,
|
||||
save_dirs: HashMap::new(),
|
||||
targa_options: None,
|
||||
allow_close: false,
|
||||
stylus: Default::default(),
|
||||
background_jobs: false,
|
||||
@@ -1099,8 +1106,14 @@ impl PhotocraftApp {
|
||||
/// continues on a later frame, see `file_dialog`). Returns `{"path", "warnings"}` (the export
|
||||
/// warnings are also shown to the user).
|
||||
pub fn save_as(&mut self, path: Option<String>) -> Result<Value, String> {
|
||||
if self.tiff_options.is_some() {
|
||||
return Err("Answer TIFF Options before starting another save".into());
|
||||
self.save_as_with(path, None)
|
||||
}
|
||||
|
||||
/// [`Self::save_as`], writing a Targa with `tga_bits` per pixel when given (no Targa Options
|
||||
/// prompt): `file.save` and `file.saveAs` with `tgaBits`.
|
||||
pub fn save_as_with(&mut self, path: Option<String>, tga_bits: Option<photocraft_io::TgaBits>) -> Result<Value, String> {
|
||||
if self.save_options_open() {
|
||||
return Err("Answer TIFF or Targa Options before starting another save".into());
|
||||
}
|
||||
// Edit Contents documents save back into their smart object.
|
||||
if path.is_none() && self.session.is_enabled("layer.smartObjects.saveContents") {
|
||||
@@ -1108,7 +1121,7 @@ impl PhotocraftApp {
|
||||
return Ok(serde_json::json!({"path": "smart object", "warnings": []}));
|
||||
}
|
||||
if let Some(path) = path {
|
||||
return self.save_to(path);
|
||||
return self.save_to(path, tga_bits);
|
||||
}
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
// PDN imports default to our native format, which preserves Paint.NET's blend modes.
|
||||
@@ -1130,47 +1143,58 @@ impl PhotocraftApp {
|
||||
}
|
||||
};
|
||||
let doc = st.doc.id;
|
||||
self.pick_save(&suggested, move |app, path| app.with_document(doc, |app| app.save_to(path)))
|
||||
self.pick_save(&suggested, move |app, path| app.with_document(doc, |app| app.save_to(path, tga_bits)))
|
||||
}
|
||||
|
||||
/// [`Self::save_as`] once the path is known.
|
||||
fn save_to(&mut self, path: String) -> Result<Value, String> {
|
||||
/// [`Self::save_as_with`] once the path is known.
|
||||
fn save_to(&mut self, path: String, tga_bits: Option<photocraft_io::TgaBits>) -> Result<Value, String> {
|
||||
// An RGB Targa asks for its bits per pixel, unless the caller chose them; the save
|
||||
// continues from the prompt (still a copy when it would be one without the prompt),
|
||||
// which records the step once answered.
|
||||
if tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) {
|
||||
let copy = self.saves_copy(&path, None)?;
|
||||
targa_options_ui::park(self, path.clone(), copy, true)?;
|
||||
return Ok(serde_json::json!({"path": path, "warnings": []}));
|
||||
}
|
||||
// A layered TIFF asks about its layers first (Preferences › File Handling); the save
|
||||
// continues from the prompt, which records the step once answered.
|
||||
if tiff_options_ui::wants_prompt(self, &path) {
|
||||
tiff_options_ui::park(self, path.clone())?;
|
||||
return Ok(serde_json::json!({"path": path, "warnings": []}));
|
||||
}
|
||||
self.save_as_step(path, None)
|
||||
self.save_as_step(path, None, tga_bits)
|
||||
}
|
||||
|
||||
/// File › Save back to the document's own layered file, recorded as a `file.save` step.
|
||||
pub(crate) fn save_in_place(&mut self, path: String) -> Result<Value, String> {
|
||||
if self.tiff_options.is_some() {
|
||||
return Err("Answer TIFF Options before starting another save".into());
|
||||
if self.save_options_open() {
|
||||
return Err("Answer TIFF or Targa Options before starting another save".into());
|
||||
}
|
||||
let r = self.write_save(path, None)?;
|
||||
let r = self.write_save(path, None, None)?;
|
||||
self.session.journal.push(("file.save".into(), Value::Object(Default::default())));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF
|
||||
/// answer), so an action being recorded keeps it (#2032).
|
||||
fn save_as_step(&mut self, path: String, tiff_layers: Option<bool>) -> Result<Value, String> {
|
||||
let r = self.write_save(path.clone(), tiff_layers)?;
|
||||
/// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF or
|
||||
/// Targa answer), so an action being recorded keeps it (#2032).
|
||||
fn save_as_step(&mut self, path: String, tiff_layers: Option<bool>, tga_bits: Option<photocraft_io::TgaBits>) -> Result<Value, String> {
|
||||
let r = self.write_save(path.clone(), tiff_layers, tga_bits)?;
|
||||
let mut step = serde_json::json!({"path": path});
|
||||
if let Some(layers) = tiff_layers {
|
||||
step["tiffLayers"] = layers.into();
|
||||
}
|
||||
if let Some(bits) = tga_bits {
|
||||
step["tgaBits"] = bits.bits().into();
|
||||
}
|
||||
self.session.journal.push(("file.saveAs".into(), step));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Replays a recorded `file.save` / `file.saveAs` step: no dialog or prompt, and the
|
||||
/// recorded TIFF answer (if any) stands in for the TIFF Options prompt.
|
||||
/// recorded TIFF or Targa answer (if any) stands in for the TIFF or Targa Options prompt.
|
||||
pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result<Value, String> {
|
||||
if self.tiff_options.is_some() {
|
||||
return Err("Answer TIFF Options before starting another save".into());
|
||||
if self.save_options_open() {
|
||||
return Err("Answer TIFF or Targa Options before starting another save".into());
|
||||
}
|
||||
if id == "file.save" {
|
||||
let path = self
|
||||
@@ -1184,20 +1208,28 @@ impl PhotocraftApp {
|
||||
return self.save_in_place(path);
|
||||
}
|
||||
let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?;
|
||||
self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool))
|
||||
let tga_bits = targa_options_ui::bits_param(params, id)?;
|
||||
self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), tga_bits)
|
||||
}
|
||||
|
||||
/// Writes the active document to a known path. `tiff_layers` is the TIFF Options answer:
|
||||
/// `Some(false)` discards the layers and saves a copy.
|
||||
fn write_save(&mut self, path: String, tiff_layers: Option<bool>) -> Result<Value, String> {
|
||||
/// Whether saving the active document to `path` writes a copy. `tiff_layers` is the TIFF
|
||||
/// Options answer: `Some(false)` discards the layers and saves a copy.
|
||||
fn saves_copy(&self, path: &str, tiff_layers: Option<bool>) -> Result<bool, String> {
|
||||
// A flat file that can't hold the document (its layers, or the layered file it lives in)
|
||||
// is written as a copy, as in Photoshop: the document keeps its file, so Save still
|
||||
// writes the layered original and the edits stay unsaved (#2550).
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
let layered =
|
||||
|p: &str| photocraft_engine::file_cmds::saves_in_place(p) || matches!(photocraft_engine::file_cmds::extension(p).as_deref(), Some("tif" | "tiff"));
|
||||
let copy = tiff_layers == Some(false) || (!layered(&path) && (plain_raster(&st.doc).is_none() || st.path.as_deref().is_some_and(layered)));
|
||||
let settings = ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), ..Default::default() };
|
||||
Ok(tiff_layers == Some(false) || (!layered(path) && (plain_raster(&st.doc).is_none() || st.path.as_deref().is_some_and(layered))))
|
||||
}
|
||||
|
||||
/// Writes the active document to a known path. `tiff_layers` is the TIFF Options answer:
|
||||
/// `Some(false)` discards the layers and saves a copy. `tga_bits` is the Targa bits per
|
||||
/// pixel (`None`: 32 with an alpha channel or transparency, else 24).
|
||||
fn write_save(&mut self, path: String, tiff_layers: Option<bool>, tga_bits: Option<photocraft_io::TgaBits>) -> Result<Value, String> {
|
||||
let copy = self.saves_copy(&path, tiff_layers)?;
|
||||
let settings = ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), tga_bits, ..Default::default() };
|
||||
match self.write_document(path.clone(), &settings, copy)? {
|
||||
Some((path, warnings)) => Ok(serde_json::json!({"path": path, "warnings": warnings})),
|
||||
None => Ok(serde_json::json!({"path": path, "warnings": [], "pending": true, "job": self.jobs.last_started.map(|j| j.0)})),
|
||||
@@ -1271,6 +1303,11 @@ impl PhotocraftApp {
|
||||
self.sync_views();
|
||||
}
|
||||
|
||||
/// TIFF Options or Targa Options is waiting on an answer before a save is written.
|
||||
pub(crate) fn save_options_open(&self) -> bool {
|
||||
self.tiff_options.is_some() || self.targa_options.is_some()
|
||||
}
|
||||
|
||||
/// A save is running in the background.
|
||||
pub(crate) fn saving(&self) -> bool {
|
||||
!self.jobs.saves.is_empty()
|
||||
@@ -1280,6 +1317,12 @@ impl PhotocraftApp {
|
||||
/// picker or ambient writer is reachable from this path. Returns the path
|
||||
/// written and the export warnings (also shown to the user).
|
||||
pub fn save_automation(&mut self, path: Option<String>) -> Result<Value, String> {
|
||||
self.save_automation_with(path, None)
|
||||
}
|
||||
|
||||
/// [`Self::save_automation`] writing a Targa with `tga_bits` per pixel (`None`: 32 with an
|
||||
/// alpha channel or transparency, else 24). Automation saves never prompt.
|
||||
pub fn save_automation_with(&mut self, path: Option<String>, tga_bits: Option<photocraft_io::TgaBits>) -> Result<Value, String> {
|
||||
let target = {
|
||||
let state = self.session.active().ok_or("no document")?;
|
||||
// As File › Save: without `path` only a layered file is written back (#416).
|
||||
@@ -1291,7 +1334,7 @@ impl PhotocraftApp {
|
||||
if self.background_jobs
|
||||
&& let Some(save) = self.services.automation_save.clone()
|
||||
{
|
||||
return match jobs_ui::start_save(self, target.clone(), ExportSettings::default(), false, save, true)? {
|
||||
return match jobs_ui::start_save(self, target.clone(), ExportSettings { tga_bits, ..Default::default() }, false, save, true)? {
|
||||
Some((path, warnings)) => Ok(reply(&path, &warnings)),
|
||||
None => {
|
||||
let job = self.jobs.last_started;
|
||||
@@ -1302,7 +1345,7 @@ impl PhotocraftApp {
|
||||
let state = self.session.active().ok_or("no document")?;
|
||||
let (doc, revision) = (state.doc.id, state.revision);
|
||||
let export = self.services.export.as_ref().ok_or("no exporter configured")?;
|
||||
let (bytes, warnings) = export(&state.doc, &target, &ExportSettings::default())?;
|
||||
let (bytes, warnings) = export(&state.doc, &target, &ExportSettings { tga_bits, ..Default::default() })?;
|
||||
let write = self.services.automation_write.as_mut().ok_or("automation write authority is not configured")?;
|
||||
write(&target, &bytes)?;
|
||||
self.saved_automation(doc, revision, &target, &warnings, false);
|
||||
@@ -1549,6 +1592,7 @@ impl eframe::App for PhotocraftApp {
|
||||
jobs_ui::dialog(self, &ctx);
|
||||
discard_ui::show(self, &ctx);
|
||||
tiff_options_ui::show(self, &ctx);
|
||||
targa_options_ui::show(self, &ctx);
|
||||
distort_ui::show(self, &ctx);
|
||||
camera_raw_ui::show(self, &ctx);
|
||||
wide_angle_ui::show(self, &ctx);
|
||||
|
||||
@@ -281,11 +281,12 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
}
|
||||
"file.save" => {
|
||||
// Writes back only to a layered file; a flat one goes through Save As.
|
||||
let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?;
|
||||
match params.get("path").and_then(Value::as_str) {
|
||||
Some(path) => app.save_as(Some(path.to_string())),
|
||||
Some(path) => app.save_as_with(Some(path.to_string()), tga_bits),
|
||||
None => match app.session.active().and_then(|d| d.path.clone()).filter(|p| photocraft_engine::file_cmds::saves_in_place(p)) {
|
||||
Some(path) => app.save_in_place(path),
|
||||
None => app.save_as(None),
|
||||
None => app.save_as_with(None, tga_bits),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -316,7 +317,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
Err("Open Recent is unavailable on the web".to_string())
|
||||
}
|
||||
}
|
||||
"file.saveAs" => app.save_as(params.get("path").and_then(Value::as_str).map(str::to_string)),
|
||||
"file.saveAs" => {
|
||||
let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?;
|
||||
app.save_as_with(params.get("path").and_then(Value::as_str).map(str::to_string), tga_bits)
|
||||
}
|
||||
"view.zoomIn" | "view.zoomOut" | "view.fitOnScreen" | "view.actualPixels" => app.run(id, params),
|
||||
"window.newWindowForDocument" => {
|
||||
let doc = app.session.active_index().ok_or("no document")?;
|
||||
|
||||
@@ -487,7 +487,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
|| app.ui.brush_picker.is_some()
|
||||
|| app.ui.tool == Tool::RotateView
|
||||
|| app.wide_angle.is_some()
|
||||
|| app.tiff_options.is_some()
|
||||
|| app.save_options_open()
|
||||
|| app.distort.gallery.is_some()
|
||||
|| ctx.any_popup_open()
|
||||
|| ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::new("tool-flyout")).is_some());
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Targa Options: the bits per pixel File › Save As and Save a Copy ask for before writing an RGB
|
||||
//! Targa, as Photoshop's Targa Options does (its Resolution; 16 bits/pixel isn't offered). 32 bits
|
||||
//! write the document's alpha channel as the Targa's alpha, so a game texture keeps the mask its
|
||||
//! alpha holds (see `photocraft_io::TgaBits`).
|
||||
//!
|
||||
//! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written
|
||||
//! once answered. Cancel drops the save. Scripts skip the prompt by passing `tgaBits`.
|
||||
|
||||
use photocraft_io::TgaBits;
|
||||
|
||||
use crate::{ExportSettings, PhotocraftApp};
|
||||
|
||||
/// A save waiting on the Targa Options answer.
|
||||
pub struct Prompt {
|
||||
/// The document that requested the save, even if tabs change while this is open.
|
||||
doc: photocraft_doc::DocId,
|
||||
/// Where the document goes.
|
||||
pub path: String,
|
||||
/// The bits per pixel chosen, starting at `photocraft_io::tga_default_bits`.
|
||||
pub bits: TgaBits,
|
||||
/// Save a Copy: the document keeps its path and unsaved changes.
|
||||
pub copy: bool,
|
||||
/// File › Save As: the answered save is recorded as a `file.saveAs` step, so an action being
|
||||
/// recorded keeps it (#2032). The dialog's Save a Copy isn't recorded.
|
||||
pub record: bool,
|
||||
}
|
||||
|
||||
/// `true` when `path` names a Targa (`.tga`, and the rarer `.icb`, `.vda`, `.vst`).
|
||||
pub fn is_tga_path(path: &str) -> bool {
|
||||
photocraft_engine::file_cmds::extension(path).and_then(|e| photocraft_codecs::from_extension(&e)) == Some(photocraft_codecs::Format::Tga)
|
||||
}
|
||||
|
||||
/// Whether saving the active document to `path` asks for Targa Options: an RGB Targa. Grayscale
|
||||
/// Targas are 8-bit, with nothing to choose.
|
||||
pub fn wants_prompt(app: &PhotocraftApp, path: &str) -> bool {
|
||||
is_tga_path(path) && app.session.active().is_some_and(|st| st.doc.mode != photocraft_color::ColorMode::Grayscale)
|
||||
}
|
||||
|
||||
/// The Targa bits a command or control call asks for (`"tgaBits": 24 | 32`), which skip the prompt.
|
||||
pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result<Option<TgaBits>, String> {
|
||||
photocraft_engine::file_cmds::tga_bits_param(params, cmd).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Parks the save behind the prompt, starting at 32 bits when the document has an alpha channel.
|
||||
/// A prompt already waiting is never replaced (a shortcut can still reach Save a Copy).
|
||||
pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: bool) -> Result<(), String> {
|
||||
if app.save_options_open() {
|
||||
return Err("Answer TIFF or Targa Options before starting another save".into());
|
||||
}
|
||||
let doc = app.active_doc_id()?;
|
||||
let bits = app.session.active().map_or(TgaBits::Bits24, |st| photocraft_io::tga_default_bits(&st.doc));
|
||||
app.targa_options = Some(Prompt { doc, path, bits, copy, record });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let Some(p) = &mut app.targa_options else { return };
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
let mut bits = p.bits;
|
||||
let modal = egui::Modal::new(egui::Id::new("targa-options")).show(ctx, |ui| {
|
||||
ui.set_max_width(340.0);
|
||||
ui.label(egui::RichText::new(tl!("Targa Options")).font(crate::theme::semibold(15.0)));
|
||||
ui.add_space(4.0);
|
||||
crate::widgets::hairline(ui);
|
||||
ui.add_space(8.0);
|
||||
crate::widgets::section_label(ui, tl!("Resolution"));
|
||||
ui.radio_value(&mut bits, TgaBits::Bits24, tl!("24 bits/pixel"));
|
||||
ui.radio_value(&mut bits, TgaBits::Bits32, tl!("32 bits/pixel"));
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
ok = crate::widgets::primary_button(ui, tl!("OK"), 84.0).clicked();
|
||||
cancel = crate::widgets::secondary_button(ui, tl!("Cancel"), 84.0).clicked();
|
||||
});
|
||||
});
|
||||
cancel |= modal.should_close();
|
||||
p.bits = bits;
|
||||
if ok || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
|
||||
let Some(p) = app.targa_options.take() else { return };
|
||||
let settings = ExportSettings { tga_bits: Some(p.bits), ..Default::default() };
|
||||
let step = serde_json::json!({"path": p.path, "tgaBits": p.bits.bits()});
|
||||
let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy));
|
||||
if written.is_ok() && p.record {
|
||||
// An action being recorded keeps the Save As with this answer (#2032).
|
||||
app.session.journal.push(("file.saveAs".into(), step));
|
||||
}
|
||||
match written {
|
||||
Err(e) => {
|
||||
app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]);
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
Ok(Some(_)) if !p.copy => crate::discard_ui::saved_document(app, ctx, p.doc),
|
||||
// A background save releases the close prompt when it finishes (`jobs_ui`).
|
||||
Ok(_) => {}
|
||||
}
|
||||
} else if cancel {
|
||||
app.targa_options = None;
|
||||
app.ui.status = tl!("Save cancelled").into();
|
||||
app.ui.status_error = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn targa_names() {
|
||||
assert!(is_tga_path("a.tga"));
|
||||
assert!(is_tga_path("dir/sub/a.TGA"));
|
||||
assert!(is_tga_path("C:\\x\\y.vda"));
|
||||
assert!(!is_tga_path("a.tga.psd"));
|
||||
assert!(!is_tga_path(".tga"));
|
||||
assert!(!is_tga_path("tga"));
|
||||
assert!(!is_tga_path("a.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bits_params() {
|
||||
use serde_json::json;
|
||||
assert_eq!(bits_param(&json!({}), "file.saveAs"), Ok(None));
|
||||
assert_eq!(bits_param(&json!({"tgaBits": null}), "file.saveAs"), Ok(None));
|
||||
assert_eq!(bits_param(&json!({"tgaBits": 24}), "file.saveAs"), Ok(Some(TgaBits::Bits24)));
|
||||
assert_eq!(bits_param(&json!({"tgaBits": 32}), "file.saveAs"), Ok(Some(TgaBits::Bits32)));
|
||||
for bad in [json!(16), json!("32"), json!(32.5), json!(false)] {
|
||||
assert!(bits_param(&json!({"tgaBits": bad}), "file.saveAs").is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1163,6 +1163,17 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let suggested = format!("{stem} copy.psd");
|
||||
// The copy is of the document as it was when asked.
|
||||
app.pick_save(&suggested, move |app, path| {
|
||||
// An RGB Targa asks for its bits per pixel first; the copy is written from the prompt.
|
||||
let parked = app.with_document(doc.id, |app| {
|
||||
let ask = crate::targa_options_ui::wants_prompt(app, &path);
|
||||
if ask {
|
||||
crate::targa_options_ui::park(app, path.clone(), true, false)?;
|
||||
}
|
||||
Ok(ask)
|
||||
})?;
|
||||
if parked {
|
||||
return Ok(json!({"path": path, "warnings": []}));
|
||||
}
|
||||
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
|
||||
let (bytes, warnings) = export(&doc, &path, &crate::ExportSettings::default())?;
|
||||
let write = app.services.write.as_mut().ok_or("no writer configured")?;
|
||||
|
||||
@@ -73,7 +73,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
base64 PNG data; a path is relative to the automation write root. Raises the window first
|
||||
(default) because occluded macOS windows stop rendering
|
||||
- `ui.focus`: bring the main window to the front
|
||||
- `app.open {path}` / `app.save {path?}`: 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, `..` 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`, `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. 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?}`: 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, `..` 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`, `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. Automation saves never show TGA 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
|
||||
@@ -254,7 +254,7 @@ How each MCP tool maps onto control methods in bridge mode:
|
||||
| `doc_new {…}` | `engine.execute {command: "file.new", params}` |
|
||||
| `doc_inspect` | `engine.execute {command: "document.inspect"}` |
|
||||
| `doc_open {path}` | `app.open {path}` |
|
||||
| `doc_save {path?}` / `doc_export {path}` | `app.save {path?}` |
|
||||
| `doc_save {path?, tgaBits?}` / `doc_export {path, tgaBits?}` | `app.save {path?, tgaBits?}` |
|
||||
| `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 |
|
||||
@@ -307,7 +307,7 @@ no MCP framing, no app start-up per command. Configure its file access with the
|
||||
| `engine.commands` | `{filter?}`: registry with params docs and enablement |
|
||||
| `session.list` | open documents and the active index |
|
||||
| `doc.open` / `doc.new` | `{path}` / `file.new` params |
|
||||
| `doc.save` | `{path?, format?, quality?, index?, tiffLayers?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF). 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?, tgaBits?}` (`.pcraft` native, else export by extension; set `tiffLayers: true` to preserve TIFF layers rather than exporting a flattened TIFF; `tgaBits` 24 or 32 sets a TGA's bits per pixel, as `app.save` does). 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?}` |
|
||||
|
||||
Reference in new issue
Block a user