From a5258943552b07ebb38e5319bf93c41975df40fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 22:18:24 -0700 Subject: [PATCH 1/4] File Info: PDF export writes the document's title, author, description and keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › File Info… opens a dialog with Document Title, Author, Description, Keywords, Copyright Notice and Copyright Info URL, and the document saves them. The new file.info command reads them with {} and sets the fields it is given; "" or null clears a field, and keywords are a list or one line split at semicolons and commas. file.info.get returns the fields without editing the document, so opening the dialog does not end a Data Merge preview. Export PDF, PDF/X-4 and Print Booklet write the title, author, description and keywords into the PDF's document information and XMP. EPUB export takes the title and author from File Info, and HTML export takes the title. A title or author given to the export replaces File Info's, and an empty Document Title leaves the document's name as the title. Export PDF's dialog no longer sends the document's name as the PDF title, so the PDF takes its title from File Info; the file name still comes from the document's name. --- ROADMAP.md | 1 + crates/doc/src/build.rs | 1 + crates/doc/src/file_info.rs | 89 ++++++++++++++ crates/doc/src/lib.rs | 5 + crates/engine/src/cmd/file_info.rs | 189 +++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/epub/src/lib.rs | 34 +++++- crates/idml/src/import.rs | 1 + crates/pdf/src/export.rs | 31 +++-- crates/pdf/src/tests.rs | 32 +++++ crates/render/src/blendspace.rs | 2 + crates/ui-egui/src/dialogs.rs | 116 +++++++++++++++++- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/menus.rs | 5 + docs/gaps.md | 4 +- 15 files changed, 495 insertions(+), 19 deletions(-) create mode 100644 crates/doc/src/file_info.rs create mode 100644 crates/engine/src/cmd/file_info.rs diff --git a/ROADMAP.md b/ROADMAP.md index 153555913..2062df408 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -359,6 +359,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. +- **2026-10-23** · File › File Info… edits the document's Document Title, Author, Description, Keywords, Copyright Notice and Copyright Info URL (`file.info`), and the document saves them. PDF export (PDF/X-4 and Print Booklet included) writes the title, author, description and keywords into the PDF's document information and XMP. EPUB export takes the title and author from File Info, and HTML export takes the title. A title or author given to the export replaces File Info's, and an empty Document Title leaves the document's name as the title. No readiness percentage change. - **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index d4500f7ce..c7259f317 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -125,6 +125,7 @@ impl Document { }; let mut d = Document { title: nd.title.clone(), + file_info: Default::default(), settings, spreads: vec![], parents: vec![], diff --git a/crates/doc/src/file_info.rs b/crates/doc/src/file_info.rs new file mode 100644 index 000000000..72e79ee16 --- /dev/null +++ b/crates/doc/src/file_info.rs @@ -0,0 +1,89 @@ +//! File › File Info: the document's title, author, description, keywords and copyright, saved +//! with the document. PDF export writes the title, author, description and keywords into the +//! file's metadata, and EPUB export writes the title and author. + +use serde::{Deserialize, Serialize}; + +use crate::Document; + +/// File › File Info. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FileInfo { + /// Document Title. Empty: exports use the document's name. + pub document_title: String, + pub author: String, + pub description: String, + /// Keywords in the order they were typed, trimmed, without empty or repeated ones. + pub keywords: Vec, + pub copyright_notice: String, + pub copyright_info_url: String, +} + +impl FileInfo { + pub fn is_empty(&self) -> bool { + *self == FileInfo::default() + } +} + +/// Keywords typed as one line: separated by semicolons or commas, trimmed, empty and repeated +/// ones dropped. +pub fn parse_keywords(text: &str) -> Vec { + clean_keywords(text.split([';', ','])) +} + +/// Trimmed keywords without empty or repeated ones, in their first order. +pub fn clean_keywords<'a>(words: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + words.into_iter().map(str::trim).filter(|w| !w.is_empty() && seen.insert(*w)).map(str::to_string).collect() +} + +impl Document { + /// The title exports write into a file's metadata: File Info's Document Title, or the + /// document's name when that is empty. + pub fn metadata_title(&self) -> &str { + let t = self.file_info.document_title.trim(); + if t.is_empty() { &self.title } else { t } + } + + /// File Info's Author, if any. + pub fn metadata_author(&self) -> Option<&str> { + let a = self.file_info.author.trim(); + (!a.is_empty()).then_some(a) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::build::NewDocument; + + #[test] + fn keywords_split_at_semicolons_and_commas() { + assert_eq!(parse_keywords(" spring; catalog, shoes ;;spring , "), ["spring", "catalog", "shoes"]); + assert!(parse_keywords(" ; , ").is_empty()); + } + + #[test] + fn the_metadata_title_falls_back_to_the_document_name() { + let mut d = Document::new(&NewDocument { title: "Flyer".into(), ..Default::default() }); + assert_eq!((d.metadata_title(), d.metadata_author()), ("Flyer", None)); + d.file_info.document_title = " Spring Catalog ".into(); + d.file_info.author = " Ana ".into(); + assert_eq!((d.metadata_title(), d.metadata_author()), ("Spring Catalog", Some("Ana"))); + d.file_info.document_title = " ".into(); + assert_eq!(d.metadata_title(), "Flyer"); + } + + #[test] + fn a_document_without_file_info_saves_as_before() { + let mut d = Document::new(&NewDocument::default()); + let v = serde_json::to_value(&d).unwrap(); + assert!(v.get("fileInfo").is_none()); + d.file_info.keywords = vec!["a".into()]; + d.file_info.copyright_notice = "© 2026 Ana".into(); + let json = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&json).unwrap(); + assert_eq!(back.file_info, d.file_info); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b2d3b4f4c..841b970d8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -20,6 +20,7 @@ pub mod cjk_settings; pub mod datamerge; mod edit; pub mod endnotes; +pub mod file_info; pub mod flex; pub mod ids; pub mod index; @@ -53,6 +54,7 @@ pub use designcraft_color as color; pub use designcraft_geom as geom; pub use edit::{ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit}; pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK}; +pub use file_info::FileInfo; pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize}; pub use ids::*; pub use index::{INDEX_MARK, IndexRef}; @@ -629,6 +631,9 @@ fn is_zero(v: &u32) -> bool { #[serde(rename_all = "camelCase")] pub struct Document { pub title: String, + /// File › File Info. + #[serde(default, skip_serializing_if = "FileInfo::is_empty")] + pub file_info: FileInfo, pub settings: DocSettings, pub spreads: Vec>, pub parents: Vec>, diff --git a/crates/engine/src/cmd/file_info.rs b/crates/engine/src/cmd/file_info.rs new file mode 100644 index 000000000..fe0e2424a --- /dev/null +++ b/crates/engine/src/cmd/file_info.rs @@ -0,0 +1,189 @@ +//! File › File Info: the document's title, author, description, keywords and copyright. PDF +//! export writes the title, author, description and keywords into the file's metadata, and EPUB +//! export writes the title and author. + +use designcraft_doc::FileInfo; +use designcraft_doc::file_info::{clean_keywords, parse_keywords}; +use serde_json::{Map, Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc}; +use crate::{Result, Session}; + +const ID: &str = "file.info"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.info", + "File Info…", + ["File"], + None, + "{documentTitle?, author?, description?, keywords?: [\"a\", \"b\"] | \"a; b\", copyrightNotice?, copyrightInfoUrl?}: each field given replaces the stored one (\"\" or null clears it) → the file info", + has_doc, + file_info + ), + cmd!(query "file.info.get", "File Info", [], None, "{} → the file info, without changing the document", has_doc, |s, _| Ok(to_json( + &s.doc()?.doc.file_info + ))), + ] +} + +fn file_info(s: &mut Session, p: &Value) -> Result { + let cur = s.doc()?.doc.file_info.clone(); + let Some(o) = p.as_object().filter(|o| !o.is_empty()) else { return Ok(to_json(&cur)) }; + let next = apply(&cur, o)?; + if next == cur { + return Ok(to_json(&cur)); + } + s.edit(|d, _| { + d.file_info = next; + Ok(to_json(&d.file_info)) + }) +} + +/// `cur` with the fields of `o` applied. +fn apply(cur: &FileInfo, o: &Map) -> Result { + let mut f = cur.clone(); + for (key, v) in o { + let field = match key.as_str() { + "documentTitle" => &mut f.document_title, + "author" => &mut f.author, + "description" => &mut f.description, + "copyrightNotice" => &mut f.copyright_notice, + "copyrightInfoUrl" => &mut f.copyright_info_url, + "keywords" => { + f.keywords = match v { + Value::Null => Vec::new(), + Value::String(t) => parse_keywords(t), + Value::Array(a) => { + let words: Option> = a.iter().map(Value::as_str).collect(); + clean_keywords(words.ok_or_else(|| bad(ID, "`keywords` must be strings"))?) + } + _ => return Err(bad(ID, "`keywords` must be a list of strings or one string")), + }; + continue; + } + other => return Err(bad(ID, format!("unknown field `{other}`"))), + }; + *field = match v { + Value::Null => String::new(), + Value::String(t) => t.trim().to_string(), + _ => return Err(bad(ID, format!("`{key}` must be a string"))), + }; + } + Ok(f) +} + +fn to_json(f: &FileInfo) -> Value { + json!({ + "documentTitle": f.document_title, + "author": f.author, + "description": f.description, + "keywords": f.keywords, + "copyrightNotice": f.copyright_notice, + "copyrightInfoUrl": f.copyright_info_url, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "title": "flyer-v3"})).unwrap(); + s + } + + #[test] + fn file_info_sets_reads_and_clears_the_fields() { + let mut s = session(); + let empty = s.execute("file.info", &json!({})).unwrap(); + assert_eq!(empty["documentTitle"], ""); + assert_eq!(empty["keywords"], json!([])); + let r = s + .execute( + "file.info", + &json!({"documentTitle": " Spring Catalog ", "author": "Ana Ruiz", "description": "Shoes", "keywords": "spring; shoes, spring", + "copyrightNotice": "© 2026 Ana Ruiz", "copyrightInfoUrl": "https://example.com/rights"}), + ) + .unwrap(); + assert_eq!(r["documentTitle"], "Spring Catalog"); + assert_eq!(r["keywords"], json!(["spring", "shoes"])); + assert_eq!(s.execute("file.info", &json!({})).unwrap(), r, "a read returns what was set"); + let info = &s.doc().unwrap().doc.file_info; + assert_eq!((info.author.as_str(), info.copyright_info_url.as_str()), ("Ana Ruiz", "https://example.com/rights")); + assert_eq!(s.doc().unwrap().doc.title, "flyer-v3", "the document's name stays"); + // Only the fields given change; "" and null clear one. + let r = s.execute("file.info", &json!({"author": "", "keywords": [" catalog ", ""], "description": null})).unwrap(); + assert_eq!((r["author"].as_str(), r["description"].as_str(), r["documentTitle"].as_str()), (Some(""), Some(""), Some("Spring Catalog"))); + assert_eq!(r["keywords"], json!(["catalog"])); + } + + #[test] + fn file_info_is_one_undo_step_and_survives_a_save() { + let mut s = session(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + s.execute("file.info", &json!({"documentTitle": "Spring Catalog", "keywords": ["spring"]})).unwrap(); + assert_eq!(steps(&s), before + 1); + s.execute("file.info", &json!({"documentTitle": "Spring Catalog"})).unwrap(); + s.execute("file.info", &json!({})).unwrap(); + assert_eq!(steps(&s), before + 1, "no change, no undo step"); + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": "a.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(t.doc().unwrap().doc.file_info.document_title, "Spring Catalog", "saved with the document"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.file_info.is_empty()); + } + + #[test] + fn file_info_get_leaves_a_data_merge_preview_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": ""})).unwrap(); + let story = frame["story"].as_u64().unwrap(); + s.execute("data.source.select", &json!({"bytes": crate::cmd::base64_encode(b"x\nAda\n"), "name": "people.csv"})).unwrap(); + s.execute("data.placeholder.add", &json!({"field": "x", "story": story, "at": 0})).unwrap(); + s.execute("file.info", &json!({"author": "Ana Ruiz"})).unwrap(); + s.execute("data.preview", &json!({"record": 1})).unwrap(); + let r = s.execute("file.info.get", &json!({})).unwrap(); + assert_eq!(r["author"], "Ana Ruiz"); + assert!(s.doc().unwrap().preview_stash.is_some(), "the preview stays on"); + s.execute("file.info", &json!({})).unwrap(); + assert!(s.doc().unwrap().preview_stash.is_none(), "file.info is an edit, so it ends the preview"); + } + + #[test] + fn file_info_refuses_bad_fields() { + let mut s = session(); + for p in [json!({"title": "x"}), json!({"author": 3}), json!({"keywords": [1]}), json!({"keywords": {"a": 1}})] { + assert!(s.execute("file.info", &p).is_err(), "{p}"); + } + assert!(s.doc().unwrap().doc.file_info.is_empty(), "a refused call changes nothing"); + } + + #[test] + fn pdf_and_epub_export_take_the_file_info() { + let mut s = session(); + s.execute("file.info", &json!({"documentTitle": "Spring Catalog", "author": "Ana Ruiz", "keywords": ["spring", "shoes"]})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let pdf = crate::cmd::base64_decode(r["base64"].as_str().unwrap()); + let text = String::from_utf8_lossy(&pdf); + assert!(text.contains("/Title(Spring Catalog)") && text.contains("/Author(Ana Ruiz)"), "PDF metadata"); + let r = s.execute("file.exportPdf", &json!({"title": "Proof"})).unwrap(); + let pdf = crate::cmd::base64_decode(r["base64"].as_str().unwrap()); + assert!(String::from_utf8_lossy(&pdf).contains("/Title(Proof)"), "a title given to the export wins"); + for (cmd, opf_path) in [("file.exportEpub", "OEBPS/content.opf"), ("file.exportFixedEpub", "OEBPS/content.opf")] { + let r = s.execute(cmd, &json!({})).unwrap(); + let epub = crate::cmd::base64_decode(r["base64"].as_str().unwrap()); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(epub)).unwrap(); + let mut opf = String::new(); + std::io::Read::read_to_string(&mut z.by_name(opf_path).unwrap(), &mut opf).unwrap(); + assert!(opf.contains("Spring Catalog") && opf.contains("Ana Ruiz"), "{cmd}: {opf}"); + } + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1d6294ea6..7e8a5b821 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ mod editorial; mod endnotes; mod export; mod file; +mod file_info; pub(crate) mod find; pub use edit::copied_objects_text; pub use find::same_find_domain; @@ -169,6 +170,7 @@ pub fn command_specs() -> &'static [CommandSpec] { SPECS.get_or_init(|| { let mut v = Vec::new(); v.extend(file::specs()); + v.extend(file_info::specs()); v.extend(interchange::specs()); v.extend(export::specs()); v.extend(edit::specs()); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 1df759608..6e8dc0cd5 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -187,7 +187,7 @@ pub struct FixedPage { /// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> { - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); let lang = language_tag(&opts.language); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); @@ -221,7 +221,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions let rendition = format!( "\npre-paginated\nauto\n" ); - let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified }; let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine); let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png"))); write_package(&opf, &nav, entries) @@ -1361,7 +1361,7 @@ pub struct HtmlOptions { /// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the /// document's stories and graphics in reading order. pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); let language = opts.language.as_deref().unwrap_or("en"); let lang = language_tag(language); let body = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; @@ -1386,7 +1386,7 @@ fn base64(data: &[u8]) -> String { /// Export the document as a reflowable EPUB 3. pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); + let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); let Body { html: body, images, toc, .. } = body_html(doc, true, &|name, _, _| name.to_string()); @@ -1446,7 +1446,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ); } let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; - let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified }; let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}")); let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts); let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)]; @@ -2091,6 +2091,30 @@ mod tests { assert!(opf.contains("media-type=\"image/png\"")); } + /// File › File Info's Document Title and Author name the EPUB and the HTML page; the export + /// options' title and author replace them. + #[test] + fn file_info_titles_the_epub_and_the_html_page() { + let mut d = Document::new(&NewDocument { title: "flyer-v3".into(), ..Default::default() }); + d.file_info.document_title = "Spring & Summer".into(); + d.file_info.author = "Ana Ruiz".into(); + let opf = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + opf + }; + let o = opf(&d, &EpubOptions::default()); + assert!(o.contains("Spring & Summer") && o.contains("Ana Ruiz"), "{o}"); + let o = opf(&d, &EpubOptions { title: Some("Proof".into()), author: Some("Studio".into()), ..Default::default() }); + assert!(o.contains("Proof") && o.contains("Studio"), "{o}"); + let html = export_html(&d, &HtmlOptions::default()); + assert!(html.contains("Spring & Summer")); + d.file_info = Default::default(); + let o = opf(&d, &EpubOptions::default()); + assert!(o.contains("flyer-v3") && !o.contains(""), "{o}"); + } + /// "Body¹ text", a 2×2 table, then "Closing" with an endnote. fn tables_and_notes() -> Document { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 039d1e562..c087a14b3 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3249,6 +3249,7 @@ impl<'r> Importer<'r> { // Make sure built-in paragraph styles exist and are first. let mut d = Document { title, + file_info: Default::default(), settings: self.settings.clone(), spreads: std::mem::take(&mut self.spreads).into_iter().map(Arc::new).collect(), parents: std::mem::take(&mut self.parents).into_iter().map(Arc::new).collect(), diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 34934b911..ba30f8807 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -146,14 +146,8 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() }; let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let created = opts.created.or_else(now_unix); - let metadata = Metadata { - creator: "DesignCraft".into(), - producer: "DesignCraft".into(), - title: Some(title.clone()), - authors: opts.author.iter().cloned().collect(), - created: created.map(date_time), - ..Default::default() - }; + let metadata = Metadata { created: created.map(date_time), ..file_info_metadata(doc, opts.title.as_deref(), opts.author.as_deref()) }; + let meta_title = metadata.title.clone().unwrap_or_default(); let mut writer = Writer::new(settings, metadata); let pdf = writer.document_mut(); @@ -254,7 +248,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions } if opts.standard == Standard::PdfX4 { // Output intent and PDF/X identification, then check the result. - match crate::pdfx::make_pdfx4(&bytes, &title, created) { + match crate::pdfx::make_pdfx4(&bytes, &meta_title, created) { Some(b) => bytes = b, None => warnings.push("PDF/X-4: the output intent couldn't be added".into()), } @@ -340,8 +334,7 @@ fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> } let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() }; let settings = krilla::SerializeSettings { compress_content_streams: true, ..Default::default() }; - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: Some(title), ..Default::default() }; + let metadata = file_info_metadata(doc, opts.title.as_deref(), None); let mut writer = Writer::new(settings, metadata); let pdf = writer.document_mut(); let mut ex = Exporter { @@ -424,6 +417,22 @@ fn now_unix() -> Option { None } +/// The document information from File Info (title, author, description, keywords). `title` and +/// `author` from the export options replace File Info's. +fn file_info_metadata(doc: &Document, title: Option<&str>, author: Option<&str>) -> Metadata { + let info = &doc.file_info; + let description = info.description.trim(); + Metadata { + creator: "DesignCraft".into(), + producer: "DesignCraft".into(), + title: Some(title.unwrap_or_else(|| doc.metadata_title()).to_string()), + authors: author.or_else(|| doc.metadata_author()).map(str::to_string).into_iter().collect(), + description: (!description.is_empty()).then(|| description.to_string()), + keywords: info.keywords.clone(), + created: None, + } +} + fn date_time(t: i64) -> krilla::metadata::DateTime { let c = civil(t); krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 39f472ffc..fc0d1095e 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -809,6 +809,38 @@ fn empty_and_populated_export_metadata_preserve_key_presence() { assert!(s.contains("Synthetic title") && s.contains("Test author")); } +/// File › File Info goes into the PDF's document information and XMP; the export options' title +/// and author replace File Info's, and PDF/X-4 and Print Booklet keep them. +#[test] +fn file_info_is_written_into_the_pdf_metadata() { + let mut d = doc_with_text("Body"); + d.title = "flyer-v3".into(); + d.file_info.document_title = "Spring Catalog".into(); + d.file_info.author = "Ana Ruiz".into(); + d.file_info.description = "Shoes for the spring season".into(); + d.file_info.keywords = vec!["spring".into(), "shoes".into()]; + let s = uncompressed(&d, PdfOptions::default()); + for want in ["/Title(Spring Catalog)", "/Author(Ana Ruiz)", "/Subject(Shoes for the spring season)", "/Keywords(spring, shoes)"] { + assert!(s.contains(want), "missing {want}"); + } + for want in ["", "", "", "spring, shoes"] { + assert!(s.contains(want), "XMP missing {want}"); + } + assert!(!s.contains("flyer-v3"), "the document's name is not the title"); + let s = uncompressed(&d, PdfOptions { title: Some("Proof".into()), author: Some("Studio".into()), ..Default::default() }); + assert!(s.contains("/Title(Proof)") && s.contains("/Author(Studio)") && !s.contains("Ana Ruiz"), "the options win"); + let s = uncompressed(&d, PdfOptions { standard: Standard::PdfX4, ..Default::default() }); + assert!(s.contains("GTS_PDFXVersion"), "PDF/X-4"); + let booklet = export_booklet(&d, &Cache::new(), &BookletOptions::default()).unwrap(); + let b = String::from_utf8_lossy(&booklet.bytes); + for (name, s) in [("PDF/X-4", s.as_str()), ("Print Booklet", &*b)] { + for want in ["/Title(Spring Catalog)", "/Author(Ana Ruiz)", "/Subject(Shoes for the spring season)", "/Keywords(spring, shoes)"] { + assert!(s.contains(want), "{name} missing {want}"); + } + assert!(s.contains("") && s.contains("spring, shoes"), "{name} XMP"); + } +} + /// A swatch applied to a placed greyscale image colours it (#1094): the image's darkness is the /// coverage of the swatch, so a spot swatch prints on its own plate. A colour image, or a swatch /// that no longer exists, exports as the image itself. diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 79c476343..6c1a650df 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -87,6 +87,7 @@ fn same_document(old: &Document, new: &Document) -> bool { styles, assets, title, + file_info, settings, layers, swatches, @@ -125,6 +126,7 @@ fn same_document(old: &Document, new: &Document) -> bool { && old.assets.len() == assets.len() && old.assets.iter().zip(assets).all(|((a, x), (b, y))| a == b && std::sync::Arc::ptr_eq(x, y)) && old.title == *title + && old.file_info == *file_info && old.settings == *settings && old.layers == *layers && old.swatches == *swatches diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b412a06f0..78a05d0e8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1248,6 +1248,63 @@ pub fn open_document_setup(app: &mut DesignApp) { app.ui.dialog = Some(d); } +/// The fields of File › File Info, in the order the dialog shows them, with their labels. +const FILE_INFO_FIELDS: [(&str, &str); 6] = [ + ("documentTitle", "Document Title:"), + ("author", "Author:"), + ("description", "Description:"), + ("keywords", "Keywords:"), + ("copyrightNotice", "Copyright Notice:"), + ("copyrightInfoUrl", "Copyright Info URL:"), +]; + +/// File › File Info, filled from the document. Keywords show as one line, separated by semicolons. +pub fn open_file_info(app: &mut DesignApp) { + let Ok(cur) = app.session.execute("file.info.get", &json!({})) else { return }; + let mut f = Map::new(); + for (key, _) in FILE_INFO_FIELDS { + let v = match &cur[key] { + Value::Array(words) => words.iter().filter_map(Value::as_str).collect::>().join("; "), + v => v.as_str().unwrap_or_default().to_string(), + }; + f.insert(key.into(), Value::String(v)); + } + // The keywords as shown: OK sends them only when they were edited, so a keyword holding a + // semicolon or comma stays one keyword. + let shown = f.get("keywords").cloned().unwrap_or_default(); + f.insert("keywordsShown".into(), shown); + app.ui.dialog = Some(Dialog::new("fileInfo", Value::Object(f))); +} + +fn file_info_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + egui::Grid::new("file_info").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label) in FILE_INFO_FIELDS { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); + if key == "description" { + let mut s = d.s(key); + if ui.add(egui::TextEdit::multiline(&mut s).desired_rows(3).desired_width(320.0)).changed() { + d.fields.insert(key.into(), Value::String(s)); + } + } else { + text_field(ui, d, key, 320.0); + } + ui.end_row(); + } + }); +} + +/// File Info's OK: every field as typed (an empty one clears it); the keywords only when they +/// were edited. +fn confirm_file_info(app: &mut DesignApp, d: &Dialog) -> Result { + let keywords_edited = d.fields.get("keywordsShown").and_then(Value::as_str) != Some(d.s("keywords").as_str()); + let p: Map = FILE_INFO_FIELDS + .iter() + .filter(|(key, _)| *key != "keywords" || keywords_edited) + .map(|(key, _)| (key.to_string(), Value::String(d.s(key)))) + .collect(); + app.run("file.info", Value::Object(p)) +} + /// The edges of Document Setup's margins, bleed and slug (field suffixes). const SETUP_EDGES: [&str; 4] = ["Top", "Bottom", "Inside", "Outside"]; @@ -2051,6 +2108,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"), "colorTheme" => crate::i18n::tr(&app.ui.language, "Color Theme"), "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), + "fileInfo" => crate::i18n::tr(&app.ui.language, "File Info"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), @@ -2806,6 +2864,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { result = Some(true); } } + "fileInfo" => file_info_dialog(app, ui, &mut d), "numberingSection" => crate::section_options::body(app, ui, &mut d), "pdfExport" => pdf_export(app, ui, &mut d), "alert" => { @@ -3145,6 +3204,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { } r } + "fileInfo" => { + let r = confirm_file_info(app, &d); + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "paragraphStyleOptions" => { let mut d = d; let r = confirm_paragraph_style_options(app, &mut d); @@ -3353,7 +3420,9 @@ pub fn confirm(app: &mut DesignApp) -> Result { params["pages"] = json!(pages); } let title = d.s("title"); - if !title.is_empty() { + // The document's name names the file; the PDF's title then comes from File Info. + let doc_name = app.session.active().map(|st| st.doc.title.clone()).unwrap_or_default(); + if !title.is_empty() && title != doc_name { params["title"] = json!(title); } let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") }; @@ -7083,6 +7152,51 @@ mod tests { } } + /// File › File Info opens filled from the document and OK stores what was typed; Export PDF + /// then names the file after the document and takes the PDF's title from File Info. + #[test] + fn file_info_dialog_stores_the_fields_and_titles_the_pdf() { + use std::sync::{Arc, Mutex}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "flyer-v3"})).unwrap(); + app.run("file.info", json!({"author": "Ana Ruiz", "keywords": ["spring", "shoes"]})).unwrap(); + crate::menus::activate(&mut app, "file.info", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.id, "fileInfo"); + assert_eq!((d.s("documentTitle"), d.s("author"), d.s("keywords")), (String::new(), "Ana Ruiz".into(), "spring; shoes".into())); + setup_fields(&mut app, json!({"documentTitle": "Spring Catalog", "author": "", "keywords": "spring, catalog", "description": "Shoes"})); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let info = app.session.active().unwrap().doc.file_info.clone(); + assert_eq!((info.document_title.as_str(), info.author.as_str(), info.description.as_str()), ("Spring Catalog", "", "Shoes")); + assert_eq!(info.keywords, ["spring", "catalog"]); + // Keywords left as shown stay as they are, even one with a comma in it. + app.run("file.info", json!({"keywords": ["Washington, DC", "map"]})).unwrap(); + crate::menus::activate(&mut app, "file.info", &Value::Null); + setup_fields(&mut app, json!({"author": "Studio"})); + confirm(&mut app).unwrap(); + let info = app.session.active().unwrap().doc.file_info.clone(); + assert_eq!((info.author.as_str(), info.keywords.clone()), ("Studio", vec!["Washington, DC".to_string(), "map".to_string()])); + + let writes: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let sink = writes.clone(); + app.services.write = Some(Box::new(move |_: &str, bytes: &[u8]| { + sink.lock().unwrap().push(bytes.to_vec()); + Ok(()) + })); + let names: Arc>> = Arc::new(Mutex::new(Vec::new())); + let asked = names.clone(); + app.services.pick_save = Some(Box::new(move |name: &str| { + asked.lock().unwrap().push(name.to_string()); + Some("/tmp/designcraft-file-info.pdf".to_string()) + })); + open_pdf_export(&mut app); + confirm(&mut app).unwrap(); + assert_eq!(names.lock().unwrap().as_slice(), ["flyer-v3.pdf"]); + let pdf = String::from_utf8_lossy(&writes.lock().unwrap()[0]).into_owned(); + assert!(pdf.contains("/Title(Spring Catalog)") && !pdf.contains("/Title(flyer-v3)"), "the PDF's title is File Info's"); + } + #[test] fn document_setup_ok_applies_margins_bleed_and_size_in_one_step() { let mut app = document_setup_app("millimeters"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index be5f43c97..3f1a885e4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -92,6 +92,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Export PDF…", ["PDF exportieren …", "Exporter en PDF…", "Exportar PDF…", "PDF を書き出し…", "导出PDF…"]), ("Print…", ["Drucken …", "Imprimer…", "Imprimir…", "プリント…", "打印…"]), ("Document Setup…", ["Dokument einrichten …", "Format de document…", "Ajustar documento…", "ドキュメント設定…", "文档设置…"]), + ("File Info…", ["Dateiinformationen …", "Informations sur le fichier…", "Información de archivo…", "ファイル情報…", "文件信息…"]), + ("File Info", ["Dateiinformationen", "Informations sur le fichier", "Información de archivo", "ファイル情報", "文件信息"]), ("Package…", ["Verpacken …", "Assemblage…", "Empaquetar…", "パッケージ…", "打包…"]), // Edit. ("Undo", ["Rückgängig", "Annuler", "Deshacer", "取り消し", "还原"]), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d97dcdeba..fc55feba4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -352,6 +352,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", "cmd:layout.documentSetup", + "cmd:file.info", ], ), ( @@ -2518,6 +2519,10 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { crate::dialogs::open_document_setup(app); return; } + if params.is_null() && id == "file.info" { + crate::dialogs::open_file_info(app); + return; + } if params.is_null() && id == "toc.generate" { crate::dialogs::open_toc(app); return; diff --git a/docs/gaps.md b/docs/gaps.md index 6723fe1e7..2d59f214b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (gap 16 shrunk: File › File Info; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -182,7 +182,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to - Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User + Profile / Edit Original, File ▸ Open PDF / Document and Print Presets / User Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, Convert Columns, Object ▸ Interactive ▸ Convert to (form fields), View ▸ Extras badges and Story Editor options. Pages panel ▸ Parent Options (Based on Parent is From bfc36622e083407e8da867838b8bce9d9fb9c400 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:25:22 -0700 Subject: [PATCH 2/4] File Info: keywords with a comma keep their place, and the dialog stores into its own document Editing one keyword in File Info re-split the whole field, so a stored keyword holding a comma or semicolon became two. A stored keyword that is still in the field whole now stays one keyword. An Export PDF title equal to the document's name was dropped, so the PDF took File Info's title. The dialog's title now starts empty, and a title given there is always the PDF's title; without one the file is named after the document. file.info refuses parameters that are not an object; null reads the fields like {}. File Info's OK stores the fields in the document the dialog was opened for, even when another document became active meanwhile, and stores nothing once that document has closed. --- crates/doc/src/file_info.rs | 34 +++++++++++ crates/engine/src/cmd/file_info.rs | 14 ++++- crates/ui-egui/src/dialogs.rs | 90 ++++++++++++++++++++++++++---- 3 files changed, 125 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/file_info.rs b/crates/doc/src/file_info.rs index 72e79ee16..de450e687 100644 --- a/crates/doc/src/file_info.rs +++ b/crates/doc/src/file_info.rs @@ -32,6 +32,29 @@ pub fn parse_keywords(text: &str) -> Vec { clean_keywords(text.split([';', ','])) } +/// Keywords typed as one line, read as [`parse_keywords`] reads them, except that a keyword of +/// `kept` that holds a semicolon or comma stays one keyword wherever it still stands whole +/// between separators. +pub fn parse_keywords_keeping(text: &str, kept: &[String]) -> Vec { + let mut whole: Vec<&str> = kept.iter().map(|k| k.trim()).filter(|k| k.contains([';', ','])).collect(); + whole.sort_by_key(|k| std::cmp::Reverse(k.len())); + let separator = |c: char| c == ';' || c == ','; + let mut words = Vec::new(); + let mut rest = text; + loop { + rest = rest.trim_start_matches(|c: char| separator(c) || c.is_whitespace()); + if rest.is_empty() { + break; + } + let kept_here = whole.iter().find(|k| rest.strip_prefix(**k).is_some_and(|after| after.trim_start().chars().next().is_none_or(separator))); + let end = kept_here.map_or_else(|| rest.find(separator).unwrap_or(rest.len()), |k| k.len()); + let (word, after) = rest.split_at_checked(end).unwrap_or((rest, "")); + words.push(word); + rest = after; + } + clean_keywords(words) +} + /// Trimmed keywords without empty or repeated ones, in their first order. pub fn clean_keywords<'a>(words: impl IntoIterator) -> Vec { let mut seen = std::collections::HashSet::new(); @@ -64,6 +87,17 @@ mod tests { assert!(parse_keywords(" ; , ").is_empty()); } + #[test] + fn keywords_with_separators_stay_whole_when_another_one_changes() { + let kept = ["Washington, DC".to_string(), "map".into(), "a; b".into()]; + assert_eq!(parse_keywords_keeping("Washington, DC; atlas", &kept), ["Washington, DC", "atlas"]); + assert_eq!(parse_keywords_keeping("atlas, a; b ;Washington, DC", &kept), ["atlas", "a; b", "Washington, DC"]); + // Edited, a keyword is read like any other text. + assert_eq!(parse_keywords_keeping("Washington, D.C.; map", &kept), ["Washington", "D.C.", "map"]); + assert_eq!(parse_keywords_keeping("Washington, DCX", &kept), ["Washington", "DCX"]); + assert_eq!(parse_keywords_keeping(" spring; catalog, shoes ;;spring , ", &[]), ["spring", "catalog", "shoes"]); + } + #[test] fn the_metadata_title_falls_back_to_the_document_name() { let mut d = Document::new(&NewDocument { title: "Flyer".into(), ..Default::default() }); diff --git a/crates/engine/src/cmd/file_info.rs b/crates/engine/src/cmd/file_info.rs index fe0e2424a..bc6f88ba6 100644 --- a/crates/engine/src/cmd/file_info.rs +++ b/crates/engine/src/cmd/file_info.rs @@ -30,7 +30,12 @@ pub fn specs() -> Vec { fn file_info(s: &mut Session, p: &Value) -> Result { let cur = s.doc()?.doc.file_info.clone(); - let Some(o) = p.as_object().filter(|o| !o.is_empty()) else { return Ok(to_json(&cur)) }; + let o = match p { + Value::Null => return Ok(to_json(&cur)), + Value::Object(o) if o.is_empty() => return Ok(to_json(&cur)), + Value::Object(o) => o, + _ => return Err(bad(ID, "the parameters must be an object of fields")), + }; let next = apply(&cur, o)?; if next == cur { return Ok(to_json(&cur)); @@ -164,6 +169,13 @@ mod tests { assert!(s.execute("file.info", &p).is_err(), "{p}"); } assert!(s.doc().unwrap().doc.file_info.is_empty(), "a refused call changes nothing"); + // Parameters that are not an object are refused; null reads like {}. + s.execute("file.info", &json!({"author": "Ana Ruiz"})).unwrap(); + for p in [json!(3), json!("author"), json!(true), json!(["author"])] { + let e = s.execute("file.info", &p).unwrap_err().to_string(); + assert!(e.contains("must be an object"), "{p}: {e}"); + } + assert_eq!(s.execute("file.info", &serde_json::Value::Null).unwrap()["author"], "Ana Ruiz"); } #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 78a05d0e8..198960583 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -338,13 +338,13 @@ pub fn open_print(app: &mut DesignApp) { } /// File › Export PDF…: the "Export PDF" options dialog, seeded from the persisted settings -/// (`UiState.pdf_export`) and the active document. `pages` is per-job (not persisted); `flatten` -/// falls back to the global flattener preset when the persisted value is empty. +/// (`UiState.pdf_export`). `pages` is per-job (not persisted); `flatten` falls back to the global +/// flattener preset when the persisted value is empty. `title` starts empty: a title given to the +/// dialog is the PDF's title and names the file, and without one the file is named after the +/// document and the PDF takes its title from File Info. pub fn open_pdf_export(app: &mut DesignApp) { let mut fields = serde_json::to_value(&app.ui.pdf_export).unwrap_or_else(|_| json!({})); if let Value::Object(m) = &mut fields { - let title = app.session.active().map(|st| st.doc.title.clone()).unwrap_or_default(); - m.insert("title".into(), json!(title)); m.insert("pages".into(), json!("all")); if app.ui.pdf_export.flatten.is_empty() { m.insert("flatten".into(), json!(app.ui.flattener)); @@ -1269,10 +1269,13 @@ pub fn open_file_info(app: &mut DesignApp) { }; f.insert(key.into(), Value::String(v)); } - // The keywords as shown: OK sends them only when they were edited, so a keyword holding a - // semicolon or comma stays one keyword. + // The keywords as shown and as stored: OK sends them only when they were edited, and then a + // stored keyword holding a semicolon or comma that is still there whole stays one keyword. let shown = f.get("keywords").cloned().unwrap_or_default(); f.insert("keywordsShown".into(), shown); + f.insert("keywordsStored".into(), cur["keywords"].clone()); + // The document the fields belong to, should another one become active before OK. + f.insert("uid".into(), json!(app.session.active().map(|doc| doc.uid))); app.ui.dialog = Some(Dialog::new("fileInfo", Value::Object(f))); } @@ -1294,15 +1297,40 @@ fn file_info_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } /// File Info's OK: every field as typed (an empty one clears it); the keywords only when they -/// were edited. +/// were edited. The fields go to the document the dialog was opened for, even when another one +/// became active meanwhile, and the active document stays active; when the dialog's document has +/// closed, nothing is stored. fn confirm_file_info(app: &mut DesignApp, d: &Dialog) -> Result { let keywords_edited = d.fields.get("keywordsShown").and_then(Value::as_str) != Some(d.s("keywords").as_str()); + let stored: Vec = match d.fields.get("keywordsStored") { + Some(Value::Array(words)) => words.iter().filter_map(Value::as_str).map(str::to_string).collect(), + _ => Vec::new(), + }; let p: Map = FILE_INFO_FIELDS .iter() .filter(|(key, _)| *key != "keywords" || keywords_edited) - .map(|(key, _)| (key.to_string(), Value::String(d.s(key)))) + .map(|(key, _)| { + let v = if *key == "keywords" { + json!(designcraft_doc::file_info::parse_keywords_keeping(&d.s(key), &stored)) + } else { + Value::String(d.s(key)) + }; + (key.to_string(), v) + }) .collect(); - app.run("file.info", Value::Object(p)) + let uid = d.fields.get("uid").and_then(Value::as_u64); + let active = app.session.active().map(|doc| doc.uid); + if uid.is_none() || uid == active { + return app.run("file.info", Value::Object(p)); + } + let index = |app: &DesignApp, uid: Option| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); + let Some(i) = index(app, uid) else { return Ok(Value::Null) }; + app.run("file.activate", json!({"index": i}))?; + let r = app.run("file.info", Value::Object(p)); + if let Some(back) = index(app, active) { + app.run("file.activate", json!({"index": back}))?; + } + r } /// The edges of Document Setup's margins, bleed and slug (field suffixes). @@ -3420,12 +3448,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { params["pages"] = json!(pages); } let title = d.s("title"); - // The document's name names the file; the PDF's title then comes from File Info. + // Without a title the document's name names the file, and the PDF's title comes from + // File Info. let doc_name = app.session.active().map(|st| st.doc.title.clone()).unwrap_or_default(); - if !title.is_empty() && title != doc_name { + if !title.is_empty() { params["title"] = json!(title); } - let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") }; + let stem = if title.is_empty() { doc_name } else { title }; + let name = if stem.is_empty() { "Export.pdf".to_string() } else { format!("{stem}.pdf") }; match app.pick_save(&name) { Some(path) => { params["path"] = json!(path); @@ -7177,6 +7207,12 @@ mod tests { confirm(&mut app).unwrap(); let info = app.session.active().unwrap().doc.file_info.clone(); assert_eq!((info.author.as_str(), info.keywords.clone()), ("Studio", vec!["Washington, DC".to_string(), "map".to_string()])); + // Another keyword edited: the one with a comma in it still stays one keyword. + crate::menus::activate(&mut app, "file.info", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("keywords"), "Washington, DC; map"); + setup_fields(&mut app, json!({"keywords": "Washington, DC; atlas"})); + confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.file_info.keywords, ["Washington, DC", "atlas"]); let writes: Arc>>> = Arc::new(Mutex::new(Vec::new())); let sink = writes.clone(); @@ -7195,6 +7231,36 @@ mod tests { assert_eq!(names.lock().unwrap().as_slice(), ["flyer-v3.pdf"]); let pdf = String::from_utf8_lossy(&writes.lock().unwrap()[0]).into_owned(); assert!(pdf.contains("/Title(Spring Catalog)") && !pdf.contains("/Title(flyer-v3)"), "the PDF's title is File Info's"); + // A title given to the export is the PDF's title, even when it is the document's name. + open_pdf_export(&mut app); + setup_fields(&mut app, json!({"title": "flyer-v3"})); + confirm(&mut app).unwrap(); + let asked = names.lock().unwrap().last().cloned().unwrap_or_default(); + assert_eq!(std::path::Path::new(&asked).file_name().and_then(|n| n.to_str()), Some("flyer-v3.pdf"), "{asked}"); + let pdf = String::from_utf8_lossy(&writes.lock().unwrap()[1]).into_owned(); + assert!(pdf.contains("/Title(flyer-v3)") && !pdf.contains("/Title(Spring Catalog)"), "the given title wins"); + } + + /// File Info's OK stores the fields in the document the dialog was opened for, even when + /// another document became active meanwhile; once that document has closed, OK stores nothing. + #[test] + fn file_info_dialog_stores_into_the_document_it_was_opened_for() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "first"})).unwrap(); + crate::menus::activate(&mut app, "file.info", &Value::Null); + app.run("file.new", json!({"title": "second"})).unwrap(); + setup_fields(&mut app, json!({"author": "Ana Ruiz"})); + confirm(&mut app).unwrap(); + let docs = app.session.documents(); + let author = |title: &str| docs.iter().find(|d| d.doc.title == title).map(|d| d.doc.file_info.author.clone()); + assert_eq!((author("first").as_deref(), author("second").as_deref()), (Some("Ana Ruiz"), Some(""))); + assert_eq!(app.session.active().map(|d| d.doc.title.as_str()), Some("second"), "the active document stays active"); + + crate::menus::activate(&mut app, "file.info", &Value::Null); + app.run("file.close", json!({})).unwrap(); + setup_fields(&mut app, json!({"author": "Studio"})); + confirm(&mut app).unwrap(); + assert!(app.session.documents().iter().all(|d| d.doc.file_info.author != "Studio"), "the dialog's document closed"); } #[test] From 2c7bbaa79ee0eb7598c8f6fe9cedbe5271d9e8dd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:40:43 -0700 Subject: [PATCH 3/4] File Info: OK keeps the fields the dialog does not hold, and the active document's Data Merge preview --- crates/ui-egui/src/dialogs.rs | 43 +++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5f87769dd..e6945e6ba 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1411,8 +1411,8 @@ fn file_info_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } -/// File Info's OK: every field as typed (an empty one clears it); the keywords only when they -/// were edited. The fields go to the document the dialog was opened for, even when another one +/// File Info's OK: every field the dialog holds, as typed (an empty one clears it); the keywords +/// only when they were edited. The fields go to the document the dialog was opened for, even when another one /// became active meanwhile, and the active document stays active; when the dialog's document has /// closed, nothing is stored. fn confirm_file_info(app: &mut DesignApp, d: &Dialog) -> Result { @@ -1423,6 +1423,7 @@ fn confirm_file_info(app: &mut DesignApp, d: &Dialog) -> Result { }; let p: Map = FILE_INFO_FIELDS .iter() + .filter(|(key, _)| d.fields.contains_key(*key)) .filter(|(key, _)| *key != "keywords" || keywords_edited) .map(|(key, _)| { let v = if *key == "keywords" { @@ -1440,10 +1441,15 @@ fn confirm_file_info(app: &mut DesignApp, d: &Dialog) -> Result { } let index = |app: &DesignApp, uid: Option| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); let Some(i) = index(app, uid) else { return Ok(Value::Null) }; + // Switching documents ends the active document's Data Merge preview; it is shown again after. + let preview = app.session.active().and_then(|doc| doc.preview_record); app.run("file.activate", json!({"index": i}))?; let r = app.run("file.info", Value::Object(p)); if let Some(back) = index(app, active) { app.run("file.activate", json!({"index": back}))?; + if let Some(record) = preview { + app.run("data.preview", json!({"record": record}))?; + } } r } @@ -7610,6 +7616,39 @@ mod tests { assert!(app.session.documents().iter().all(|d| d.doc.file_info.author != "Studio"), "the dialog's document closed"); } + /// File Info opened through `ui.dialog.open` with only some fields stores only those fields; + /// the others keep what the document holds. + #[test] + fn file_info_opened_with_some_fields_keeps_the_others() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("file.info", json!({"documentTitle": "Spring Catalog", "keywords": ["spring", "shoes"]})).unwrap(); + assert_eq!(ctl(&mut app, "ui.dialog.open", json!({"id": "fileInfo", "fields": {"author": "Ana Ruiz", "description": ""}}))["ok"], true); + confirm(&mut app).unwrap(); + let info = app.session.active().unwrap().doc.file_info.clone(); + assert_eq!((info.document_title.as_str(), info.author.as_str(), info.description.as_str()), ("Spring Catalog", "Ana Ruiz", "")); + assert_eq!(info.keywords, ["spring", "shoes"]); + } + + /// File Info's OK for a document that is not the active one leaves the active document's + /// Data Merge preview showing the record it showed. + #[test] + fn file_info_for_another_document_keeps_the_active_preview() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "first"})).unwrap(); + crate::menus::activate(&mut app, "file.info", &Value::Null); + app.run("file.new", json!({"title": "second"})).unwrap(); + let csv = designcraft_engine::cmd::base64_encode(b"Name\nAda\nGrace\n"); + app.run("data.source.select", json!({"bytes": csv, "name": "people.csv"})).unwrap(); + app.run("data.preview", json!({"record": 2})).unwrap(); + setup_fields(&mut app, json!({"author": "Ana Ruiz"})); + confirm(&mut app).unwrap(); + let active = app.session.active().unwrap(); + assert_eq!((active.doc.title.as_str(), active.preview_record), ("second", Some(2)), "the preview still shows record 2"); + let first = app.session.documents().iter().find(|d| d.doc.title == "first").map(|d| d.doc.file_info.author.clone()); + assert_eq!(first.as_deref(), Some("Ana Ruiz")); + } + #[test] fn document_setup_ok_applies_margins_bleed_and_size_in_one_step() { let mut app = document_setup_app("millimeters"); From 346a005c2063a2cbb5a901d19dbfca2e9463e0e9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 19:39:52 -0700 Subject: [PATCH 4/4] File Info: a keyword list or null from ui.dialog.open reaches file.info as it is --- crates/ui-egui/src/dialogs.rs | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e6945e6ba..c963b027c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1412,9 +1412,10 @@ fn file_info_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } /// File Info's OK: every field the dialog holds, as typed (an empty one clears it); the keywords -/// only when they were edited. The fields go to the document the dialog was opened for, even when another one -/// became active meanwhile, and the active document stays active; when the dialog's document has -/// closed, nothing is stored. +/// only when they were edited. A field that is not text (a keyword list, or `null` from +/// `ui.dialog.open`) goes to `file.info` as it is. The fields go to the document the dialog was +/// opened for, even when another one became active meanwhile, and the active document stays +/// active; when the dialog's document has closed, nothing is stored. fn confirm_file_info(app: &mut DesignApp, d: &Dialog) -> Result { let keywords_edited = d.fields.get("keywordsShown").and_then(Value::as_str) != Some(d.s("keywords").as_str()); let stored: Vec = match d.fields.get("keywordsStored") { @@ -1426,10 +1427,10 @@ fn confirm_file_info(app: &mut DesignApp, d: &Dialog) -> Result { .filter(|(key, _)| d.fields.contains_key(*key)) .filter(|(key, _)| *key != "keywords" || keywords_edited) .map(|(key, _)| { - let v = if *key == "keywords" { - json!(designcraft_doc::file_info::parse_keywords_keeping(&d.s(key), &stored)) - } else { - Value::String(d.s(key)) + let v = match d.fields.get(*key) { + Some(Value::String(s)) if *key == "keywords" => json!(designcraft_doc::file_info::parse_keywords_keeping(s, &stored)), + Some(v) => v.clone(), + None => Value::Null, }; (key.to_string(), v) }) @@ -7630,6 +7631,21 @@ mod tests { assert_eq!(info.keywords, ["spring", "shoes"]); } + /// Fields given to `ui.dialog.open` as JSON other than text reach `file.info` as they are: a + /// keyword list stays a list, and `null` clears a field. + #[test] + fn file_info_opened_with_a_keyword_list_stores_the_list() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("file.info", json!({"author": "Ana Ruiz"})).unwrap(); + let fields = json!({"keywords": ["Washington, DC", "map"], "author": null}); + assert_eq!(ctl(&mut app, "ui.dialog.open", json!({"id": "fileInfo", "fields": fields}))["ok"], true); + confirm(&mut app).unwrap(); + let info = app.session.active().unwrap().doc.file_info.clone(); + assert_eq!(info.keywords, ["Washington, DC", "map"]); + assert_eq!(info.author, ""); + } + /// File Info's OK for a document that is not the active one leaves the active document's /// Data Merge preview showing the record it showed. #[test]