Files
photocraft/crates/automation/tests/openraster.rs
e18e3c066f OpenRaster: never overwrite an .ora with less than the document has (#3806) (#3881)
* 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>
2026-10-13 02:04:02 -04:00

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();
}