mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-30 04:47:09 -06:00
* OpenRaster: never overwrite an .ora with less than the document has (#3806) File › Save wrote any document back to its .ora, though OpenRaster drops adjustment layers, layer styles, vector masks and clipping, applies masks and fill opacity to the pixels and rasterises type and shape layers; the save then marked the document saved, so the edits were lost without asking. An OpenRaster file now counts as the document's own file only while it holds everything in it. photocraft_io::ora_losses lists what a save would leave out, from the same checks the writer warns with but without encoding anything, and file_cmds::holds says whether a format keeps the whole document (PSD, PSB and .pcraft always; OpenRaster when nothing is lost). When it doesn't: - File › Save opens Save As on a PSD of the same name, with a notice listing what OpenRaster would drop, instead of overwriting the .ora; - a save to an .ora path writes a copy, as Photoshop does for formats that can't keep everything: the document keeps its file and stays unsaved; - automation (app.save, the headless doc.save) refuses a pathless save with the reasons and treats an .ora save as a copy. An sRGB profile is no longer reported as lost, as OpenRaster readers assume sRGB. A plain document still saves back to its .ora. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n(vi): translate the OpenRaster save notice vi.tsv is now a complete catalog, so the two new strings need Vietnamese rows. Terms follow the catalog's own: Không thể for can't, như for such as, Lưu bản sao for Save a Copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE * i18n(hu, tr): translate the OpenRaster save notice main added complete Hungarian and Turkish catalogs after this branch was cut, so every_tl_literal_is_translated needs their rows for the two new strings. Wording follows each catalog: informal Hungarian with "{name}" after a colon; formal Turkish with Biçim for format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE * i18n(sr): translate the OpenRaster save notice Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs the notice's two strings there too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
106 lines
5.5 KiB
Rust
106 lines
5.5 KiB
Rust
//! OpenRaster through the agent surface (`doc.*` and `engine.execute`, as MCP and the CLI use
|
|
//! it): save a layered document as .ora, reopen it with its layers, edit it and save it back in
|
|
//! place.
|
|
|
|
use photocraft_automation::Headless;
|
|
use serde_json::json;
|
|
|
|
fn tmp(name: &str) -> std::path::PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"pc-openraster-{name}-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
|
|
h.handle("engine.execute", json!({"command": command, "params": params})).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn layered_documents_save_reopen_and_save_in_place_as_openraster() {
|
|
let dir = tmp("roundtrip");
|
|
let path = dir.join("art.ora");
|
|
let path_str = path.to_str().unwrap();
|
|
let mut h = Headless::trusted_local();
|
|
h.handle("doc.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
|
exec(&mut h, "layer.new.layer", json!({}));
|
|
exec(&mut h, "paint.stroke", json!({"points": [[4, 4], [30, 20]], "size": 6, "hardness": 1.0, "color": "#ff0000"}));
|
|
let top = h.session.active().unwrap().doc.layers.last().unwrap().id.0;
|
|
exec(&mut h, "layer.setProps", json!({"layer": top, "name": "Ink", "opacity": 0.5, "blend": "Multiply"}));
|
|
let before = photocraft_compose::flatten(&h.session.active().unwrap().doc);
|
|
|
|
let saved = h.handle("doc.save", json!({"path": path_str})).unwrap();
|
|
assert!(saved["warnings"].as_array().is_none_or(Vec::is_empty), "{saved}");
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
assert_eq!(bytes.get(30..54), Some(&b"mimetypeimage/openraster"[..]), "a stored mimetype comes first");
|
|
assert!(!h.session.active().unwrap().is_dirty(), "an OpenRaster save is a layered save");
|
|
assert_eq!(h.session.active().unwrap().path.as_deref(), Some(path_str));
|
|
|
|
let opened = h.handle("doc.open", json!({"path": path_str})).unwrap();
|
|
let doc = &h.session.active().unwrap().doc;
|
|
assert_eq!(doc.layers.len(), 2, "{opened}");
|
|
let ink = doc.layers.last().unwrap();
|
|
assert_eq!((ink.name.as_str(), ink.opacity, ink.blend), ("Ink", 0.5, photocraft_doc::BlendMode::Multiply));
|
|
assert_eq!(photocraft_compose::flatten(doc).px, before.px);
|
|
|
|
// Edit, then a pathless save writes the .ora back in place and clears dirty.
|
|
exec(&mut h, "image.adjustments.invert", json!({}));
|
|
assert!(h.session.active().unwrap().is_dirty());
|
|
h.handle("doc.save", json!({})).unwrap();
|
|
assert!(!h.session.active().unwrap().is_dirty());
|
|
let reread = photocraft_io::import("art.ora", &std::fs::read(&path).unwrap()).unwrap().document;
|
|
assert_eq!(photocraft_compose::flatten(&reread).px, photocraft_compose::flatten(&h.session.active().unwrap().doc).px);
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_openraster_file_reports_an_error() {
|
|
let dir = tmp("corrupt");
|
|
let path = dir.join("broken.ora");
|
|
std::fs::write(&path, b"PK\x03\x04 not really a zip").unwrap();
|
|
let mut h = Headless::trusted_local();
|
|
let err = h.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap_err();
|
|
assert!(err.to_string().contains("OpenRaster"), "{err}");
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|
|
|
|
/// #3806: an OpenRaster file holds only what OpenRaster can. Once the document has an adjustment
|
|
/// layer, a pathless save refuses to overwrite the .ora (saying what it would drop), and a save to
|
|
/// an .ora path writes a copy: the document keeps its file and stays unsaved.
|
|
#[test]
|
|
fn an_openraster_file_never_overwrites_what_it_cannot_hold() {
|
|
let dir = tmp("lossy");
|
|
let path = dir.join("art.ora");
|
|
let path_str = path.to_str().unwrap();
|
|
let mut h = Headless::trusted_local();
|
|
h.handle("doc.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
|
exec(&mut h, "layer.new.layer", json!({}));
|
|
exec(&mut h, "paint.stroke", json!({"points": [[4, 4], [30, 20]], "size": 6, "hardness": 1.0, "color": "#ff0000"}));
|
|
h.handle("doc.save", json!({"path": path_str})).unwrap();
|
|
let original = std::fs::read(&path).unwrap();
|
|
// A Curves adjustment layer: OpenRaster has no adjustment layers.
|
|
exec(&mut h, "layer.newAdjustmentLayer.curves", json!({}));
|
|
assert!(h.session.active().unwrap().is_dirty());
|
|
let err = h.handle("doc.save", json!({})).unwrap_err().to_string();
|
|
assert!(err.contains("adjustment layers"), "{err}");
|
|
assert_eq!(std::fs::read(&path).unwrap(), original, "the original is untouched");
|
|
assert!(h.session.active().unwrap().is_dirty(), "and the edits are still unsaved");
|
|
// Saving to an .ora path explicitly writes a copy (with the warnings), not the document's file.
|
|
let copy = dir.join("copy.ora");
|
|
let saved = h.handle("doc.save", json!({"path": copy.to_str().unwrap()})).unwrap();
|
|
assert!(saved["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("adjustment layers")))), "{saved}");
|
|
let st = h.session.active().unwrap();
|
|
assert!(st.is_dirty(), "a copy leaves the edits unsaved");
|
|
assert_eq!(st.path.as_deref(), Some(path_str), "the document keeps its file");
|
|
// As PSD it holds everything, so that becomes the document's file.
|
|
let psd = dir.join("art.psd");
|
|
h.handle("doc.save", json!({"path": psd.to_str().unwrap()})).unwrap();
|
|
let st = h.session.active().unwrap();
|
|
assert!(!st.is_dirty());
|
|
assert_eq!(st.path.as_deref(), psd.to_str());
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|