diff --git a/ROADMAP.md b/ROADMAP.md index eea096036..a83979937 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -405,6 +405,7 @@ Newest first. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **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-24** · Text frames: a custom baseline grid (Text Frame Options › Baseline Options) from an IDML file sets lines from its start by its increment. The composer read the two values in the other order, so lines landed on the wrong grid. 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..de450e687 --- /dev/null +++ b/crates/doc/src/file_info.rs @@ -0,0 +1,123 @@ +//! 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([';', ','])) +} + +/// 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(); + 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 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() }); + 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 d8d197387..e255d6d89 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}; @@ -664,6 +666,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..bc6f88ba6 --- /dev/null +++ b/crates/engine/src/cmd/file_info.rs @@ -0,0 +1,201 @@ +//! 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 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)); + } + 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"); + // 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] + 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 e3514d079..cd367df67 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -189,7 +189,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); @@ -223,7 +223,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) @@ -1391,7 +1391,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, None, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; @@ -1416,7 +1416,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, opts.split.as_ref(), &|name, _, _| name.to_string()); @@ -1476,7 +1476,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)]; @@ -2254,6 +2254,30 @@ End", 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 2fce9e2f6..8365d9f2c 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3288,6 +3288,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 00248bbab..1dfb7f44c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -169,14 +169,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(); @@ -272,7 +266,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts); 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()), } @@ -362,8 +356,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 { @@ -457,6 +450,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 663ef9169..c71e1df41 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -810,6 +810,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 f6c2fa328..ea14caaad 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 f6404c8ef..23ab08b31 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -339,13 +339,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)); @@ -1367,6 +1367,98 @@ 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 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))); +} + +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 the dialog holds, as typed (an empty one clears it); the keywords +/// 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") { + 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, _)| d.fields.contains_key(*key)) + .filter(|(key, _)| *key != "keywords" || keywords_edited) + .map(|(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) + }) + .collect(); + 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) }; + // 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 +} + /// The edges of Document Setup's margins, bleed and slug (field suffixes). const SETUP_EDGES: [&str; 4] = ["Top", "Bottom", "Inside", "Outside"]; @@ -2170,6 +2262,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"), @@ -2953,6 +3046,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), "epubExport" => epub_export(app, ui, &mut d), @@ -3293,6 +3387,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); @@ -3533,10 +3635,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { params["pages"] = json!(pages); } let title = d.s("title"); + // 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() { 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); @@ -7469,6 +7575,135 @@ 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()])); + // 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(); + 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"); + // 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"); + } + + /// 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"]); + } + + /// 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] + 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"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index baae5b5f7..48d3fe1be 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -157,6 +157,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 74fe860ea..1fd1ac0cc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -373,6 +373,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", "cmd:layout.documentSetup", + "cmd:file.info", ], ), ( @@ -2616,6 +2617,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 b6e5fc73d..ea5b70840 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-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 16: Convert Variable to Text; 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-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 16 shrunk: File › File Info; gap 16: Convert Variable to Text; 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. @@ -185,7 +185,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · update commands, Convert Bullets/Numbering 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