Save As: a saved .pcraft manifest reports the file it was saved under (#3312) (#3571)

photocraft_io::named_for_file(doc, name) serialises the document under the
file it is being written to, so a .pcraft manifest carries the identity the
save is about to adopt rather than the previous one — the same rule every
other format follows on import, where document.name comes from the file it
was read from. A bare extension names no file and changes nothing.

Applied once, in the .pcraft branch of export_stages, which export and
export_with both funnel through: the desktop Save (services.rs:242),
Export (services.rs:384), the web build (web.rs:355) and the engine file
command (file_cmds.rs:295) are covered by one site, and the previews
rendered before the bytes describe the same document as the manifest.

Scope per review: the automation funnels (files::save / save_bytes) already
landed in #3319 with their own named_for_output, so this carries no
crates/automation change; its scenarios are covered by
crates/automation/tests/native_save_name.rs on main.
This commit is contained in:
Gert Pellin - GPE authored and GitHub committed 2026-10-12 18:19:22 -04:00
1 parent 50b97605fb
commit d32d3f41bb
2 files changed
+58 -3

No files matched your search

+20 -3
View File
@@ -310,6 +310,22 @@ fn has_extension(name: &str, expected: &str) -> bool {
name.rsplit(['/', '\\']).next().and_then(|name| name.rsplit_once('.')).is_some_and(|(base, ext)| !base.is_empty() && ext.eq_ignore_ascii_case(expected))
}
/// The document as it is written to `name` (a file name, path or bare extension): a copy whose
/// name is that file's own, so a `.pcraft` manifest reports the file it lives in — the name the
/// session adopts on the save that wrote it, and the name every other format gives the document
/// when it is opened (#3312). A bare extension ("pcraft") names no file and changes nothing.
pub fn named_for_file<'a>(doc: &'a Document, name: &str) -> std::borrow::Cow<'a, Document> {
let file = name.contains(['/', '\\', '.']).then(|| name.rsplit(['/', '\\']).next().unwrap_or(name));
match file {
Some(file) if !file.is_empty() && doc.name != file => {
let mut owned = (*doc).clone();
owned.name = file.to_string();
std::borrow::Cow::Owned(owned)
}
_ => std::borrow::Cow::Borrowed(doc),
}
}
/// Exports `doc` to the format named by `name_or_ext` (a file name, path or
/// bare extension).
pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result<ExportResult, IoError> {
@@ -355,14 +371,15 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &
return ora::export(doc);
}
if ext == photocraft_format::EXTENSION {
let thumbnail = Some(photocraft_compose::thumbnail(doc, 256));
let doc = named_for_file(doc, name_or_ext);
let thumbnail = Some(photocraft_compose::thumbnail(doc.as_ref(), 256));
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(PCRAFT_THUMBNAIL_DONE);
let composite = Some(photocraft_compose::thumbnail(doc, 1024));
let composite = Some(photocraft_compose::thumbnail(doc.as_ref(), 1024));
ctl.check().map_err(|_| IoError::Cancelled)?;
ctl.progress(PCRAFT_PREVIEWS_DONE);
let previews = photocraft_format::SaveOptions { thumbnail, composite };
return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc, &previews)?, warnings: Vec::new() });
return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() });
}
if ext == "psd" || ext == "psb" {
let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", ..Default::default() };
+38
View File
@@ -0,0 +1,38 @@
//! A `.pcraft` bundle's manifest reports the file it is in (#3312): writing a document to a new
//! name must not leave the previous one inside, on every path that produces `.pcraft` bytes.
mod common;
use common::*;
use photocraft_color::{ColorMode, SampleType};
use photocraft_format::zip::ZipReader;
use photocraft_io::*;
fn doc() -> photocraft_doc::Document {
let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
d.name = "FirstTest.psd".into();
d
}
/// `document.name` as the bundle actually stores it.
fn manifest_name(bytes: &[u8]) -> String {
let archive = ZipReader::new(bytes).expect("zip");
let manifest: serde_json::Value = serde_json::from_slice(&archive.read_by_name("manifest.json", usize::MAX).expect("manifest")).expect("manifest json");
manifest["document"]["name"].as_str().expect("document.name").to_string()
}
/// Save As to a new name: the written file names itself, so reopening it agrees with the session
/// that wrote it instead of reporting the file it came from.
#[test]
fn export_names_the_manifest_after_the_file() {
let r = export(&doc(), "/tmp/somewhere/_nametest.pcraft", &ExportOptions::default()).expect("export");
assert_eq!(manifest_name(&r.bytes), "_nametest.pcraft");
assert_eq!(photocraft_format::load_from_bytes(&r.bytes).expect("load").name, "_nametest.pcraft");
}
/// A bare extension names no file and leaves the document's own name alone.
#[test]
fn a_bare_extension_names_no_file() {
let r = export(&doc(), "pcraft", &ExportOptions::default()).expect("export");
assert_eq!(manifest_name(&r.bytes), "FirstTest.psd");
}