From a5258943552b07ebb38e5319bf93c41975df40fc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 22:18:24 -0700 Subject: [PATCH] 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