diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 164e2e58a..d24ea14af 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -157,7 +157,9 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result String { std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()) } +/// What a `format` export of `doc`, whose file is `path`, writes when it isn't `doc` itself: a +/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is +/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep +/// the stored title. +pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option { + let untitled = doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); + let path = path.filter(|_| untitled && !matches!(format, "vectorcraft" | "template" | "ai"))?; + let mut d = doc.clone(); + d.title = file_stem(path); + Some(d) +} + /// The format a file name's extension names. pub fn format_for_name(name: &str) -> Option<&'static Format> { Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e)) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index b0bb4d5d3..fdbf6e083 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -365,7 +365,9 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } let expanded = expand_preset(s, C, p)?; let p = &*expanded; - let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?; + let st = s.doc()?; + let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf"); + let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), p)?; write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) } diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 36925bf41..d6c9c8bd6 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -446,6 +446,11 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result) -> Result { let cmd = plan.mode.command(); let mut own = doc_to_save(st, plan.format); + // The file the document is saved as (else its own) titles it if New's name is all it has. + let file = plan.path.as_deref().filter(|_| plan.retargets()).or(st.path.as_deref()); + if let Some(d) = super::titled_for_export(&own, file, plan.format.id) { + own = Arc::new(d); + } let mut snapshot = st.doc.clone(); if let Some(at) = stamped { stamp_doc_dates(Arc::make_mut(&mut own), at); diff --git a/crates/engine/src/tests_fileinfo.rs b/crates/engine/src/tests_fileinfo.rs index 2d4b6641d..b7c489d02 100644 --- a/crates/engine/src/tests_fileinfo.rs +++ b/crates/engine/src/tests_fileinfo.rs @@ -161,3 +161,47 @@ fn svg_metadata_carries_file_info_as_dublin_core() { let plain = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg"})).unwrap())).unwrap(); assert!(!plain.contains("") && plain.contains("")); } + +/// The PDF `document.exportPdf` and `document.export` write of the active document (text +/// uncompressed, so its metadata reads). +fn pdf_texts(s: &mut Session) -> [String; 2] { + let p = json!({"compression": {"compressText": false}, "preserveEditing": false}); + let a = b64(&s.execute("document.exportPdf", &p).unwrap()); + let b = b64(&s.execute("document.export", &json!({"format": "pdf", "compression": {"compressText": false}, "preserveEditing": false})).unwrap()); + [a, b].map(|pdf| String::from_utf8_lossy(&pdf).into_owned()) +} + +/// The XMP `dc:title` of a PDF's text. +fn xmp_title(pdf: &str) -> &str { + let from = pdf.find("").expect("XMP title"); + &pdf[from..from + pdf[from..].find("").unwrap()] +} + +#[test] +fn an_untitled_document_saved_to_a_file_exports_titled_after_it() { + let dir = std::env::temp_dir().join(format!("vc-pdftitle-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Document C.vectorcraft"); + // Saved as a file, then exported: Untitled-1 was only the name New gave it. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + s.execute("document.save", &json!({"path": path.to_str().unwrap()})).unwrap(); + for pdf in pdf_texts(&mut s) { + assert!(pdf.contains("/Title(Document C)") && xmp_title(&pdf).contains("Document C"), "{}", xmp_title(&pdf)); + } + // Opened from that file, in another session. + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap(); + for pdf in pdf_texts(&mut s) { + assert!(pdf.contains("/Title(Document C)") && !pdf.contains("Untitled"), "{}", xmp_title(&pdf)); + } + // The document's own title is left as it was. + assert_eq!(s.doc().unwrap().doc.title, "Untitled-1"); + // A title of its own (File Info, New Document's Name) stays. + let mut s = session(); + s.execute("document.save", &json!({"path": dir.join("Document D.vectorcraft").to_str().unwrap()})).unwrap(); + for pdf in pdf_texts(&mut s) { + assert!(pdf.contains("/Title(Poster)") && xmp_title(&pdf).contains("Poster")); + } + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 42cf8bec6..ee85b4402 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -618,6 +618,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option st.doc.clone(), std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), }; + let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); let path = target_path(app, path, f.extensions[0])?; let mut again = params.clone(); if let Some(o) = again.as_object_mut() {