mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-15 14:43:28 +00:00
Default layered flat-image saves to PSD (#2234)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
1 parent
eb20f83654
commit
97f37feaec
2 files changed
+127
-2
No files matched your search
@@ -40,6 +40,120 @@ fn pdn_save_requests_a_native_copy_without_overwriting_the_source() {
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/layers.pcraft"));
|
||||
}
|
||||
|
||||
/// A real flat JPEG import, optionally edited to contain another layer. A filename without a
|
||||
/// path is the web/download case; desktop opens also retain their source location.
|
||||
fn jpeg_app(path: Option<&str>, layered: bool) -> (PhotocraftApp, Open, Rc<RefCell<Vec<String>>>) {
|
||||
let (mut app, open, written) = app();
|
||||
let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "My image.JPEG", &Default::default()).unwrap().bytes;
|
||||
let doc = photocraft_io::import("My image.JPEG", &bytes).unwrap().document;
|
||||
app.session.add_document(doc, path.map(str::to_string));
|
||||
if layered {
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
}
|
||||
(app, open, written)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_flat_source_save_defaults_to_psd_without_overwriting_source() {
|
||||
let ctx = egui::Context::default();
|
||||
for command in ["file.save", "file.saveAs"] {
|
||||
let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true);
|
||||
menus::invoke(&mut app, &ctx, command, json!({})).unwrap();
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert!(
|
||||
matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "/pics/My image.psd"),
|
||||
"{command}: {:?}",
|
||||
open.borrow().first().map(|(r, _)| r.clone())
|
||||
);
|
||||
assert!(written.borrow().is_empty(), "asking where to save must not overwrite the JPEG");
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/My image.JPEG"));
|
||||
answer(&open, Some(FileDialogAnswer::SaveTo("/pics/My image.psd".into())));
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert_eq!(*written.borrow(), ["/pics/My image.psd"]);
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/My image.psd"));
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_filename_only_save_defaults_to_psd() {
|
||||
let (mut app, open, written) = jpeg_app(None, true);
|
||||
let ctx = egui::Context::default();
|
||||
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "My image.psd"));
|
||||
answer(&open, Some(FileDialogAnswer::SaveTo("My image.psd".into())));
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert_eq!(*written.borrow(), ["My image.psd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_dialog_preserves_flat_and_existing_layered_format_defaults() {
|
||||
let ctx = egui::Context::default();
|
||||
for (path, layered) in [
|
||||
("/pics/My image.JPEG", false),
|
||||
("/pics/My image.PSD", true),
|
||||
("/pics/My image.psb", true),
|
||||
("/pics/My image.pcraft", true),
|
||||
("/pics/My image.TIF", true),
|
||||
("/pics/My image.tiff", true),
|
||||
] {
|
||||
let (mut app, open, written) = jpeg_app(Some(path), layered);
|
||||
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == path), "{path}");
|
||||
answer(&open, None);
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert!(written.borrow().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_png_keeps_its_format_until_its_lone_layer_becomes_smart() {
|
||||
let (mut app, open, written) = app();
|
||||
std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).layers[0].surface_mut().unwrap().write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
|
||||
let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "transparent.PNG", &Default::default()).unwrap().bytes;
|
||||
let doc = photocraft_io::import("transparent.PNG", &bytes).unwrap().document;
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
assert!(!doc.layers[0].locks.transparency, "transparent PNG imports as an unlocked raster layer");
|
||||
assert_eq!(doc.layers[0].surface().unwrap().pixel(0, 0)[3], 0.0);
|
||||
app.session.add_document(doc, Some("/pics/transparent.PNG".into()));
|
||||
let ctx = egui::Context::default();
|
||||
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "/pics/transparent.PNG"));
|
||||
answer(&open, None);
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1);
|
||||
assert!(matches!(app.session.active().unwrap().doc.layers[0].content, photocraft_doc::LayerContent::Smart(_)));
|
||||
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "/pics/transparent.psd"));
|
||||
assert!(written.borrow().is_empty());
|
||||
answer(&open, None);
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_save_respects_explicit_jpeg_choices() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true);
|
||||
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
answer(&open, Some(FileDialogAnswer::SaveTo("/exports/chosen.JPEG".into())));
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert_eq!(*written.borrow(), ["/exports/chosen.jpeg"]);
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/exports/chosen.jpeg"));
|
||||
// Explicit command paths carry the same choice and bypass the picker.
|
||||
for command in ["file.save", "file.saveAs"] {
|
||||
let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true);
|
||||
menus::invoke(&mut app, &ctx, command, json!({"path": "/exports/chosen.jpg"})).unwrap();
|
||||
assert!(!app.file_dialog_open() && open.borrow().is_empty());
|
||||
assert_eq!(*written.borrow(), ["/exports/chosen.jpg"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The user answers the open dialog, from the dialog's own thread.
|
||||
fn answer(open: &Open, answer: Option<FileDialogAnswer>) {
|
||||
let (_, reply) = open.borrow_mut().pop().expect("a dialog is open");
|
||||
|
||||
@@ -1002,10 +1002,21 @@ impl PhotocraftApp {
|
||||
}
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
// PDN imports default to our native format, which preserves Paint.NET's blend modes.
|
||||
// Other files keep their format when writable, otherwise switch to .psd.
|
||||
// Other files keep their format only when it can retain their layers. A plain raster
|
||||
// (including an unlocked transparent PNG) can keep its flat format; editable contents,
|
||||
// masks and layer appearance settings need a layered format even with just one layer.
|
||||
let plain_raster = matches!(st.doc.layers.as_slice(), [layer]
|
||||
if matches!(layer.content, photocraft_doc::LayerContent::Raster(_))
|
||||
&& layer.visible && layer.opacity >= 1.0 && layer.fill_opacity >= 1.0
|
||||
&& layer.mask.is_none() && layer.vector_mask.is_none()
|
||||
&& layer.effects.items.is_empty() && layer.effects.psd_raw.is_none()
|
||||
&& matches!(layer.blend, photocraft_color::BlendMode::Normal | photocraft_color::BlendMode::PassThrough)
|
||||
&& photocraft_compose::channel_weights(layer, st.doc.mode).is_none()
|
||||
&& !photocraft_compose::blend_if_active(layer, st.doc.mode));
|
||||
let ext = st.path.as_deref().and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||
let writable = ext.is_some_and(|e| {
|
||||
matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb") || photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)
|
||||
matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb" | "tif" | "tiff")
|
||||
|| (plain_raster && photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write))
|
||||
});
|
||||
let suggested = match &st.path {
|
||||
Some(p) if writable => p.clone(),
|
||||
|
||||
Reference in new issue
Block a user