diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 5756600281..c5ef4d5579 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -651,6 +651,7 @@ mod portable { impl Answer { /// The answer to `request` when it couldn't be read: `why`, or no files. + #[cfg(any(test, target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn failed(request: Request, why: String) -> Self { match request { Request::Text => Self::Text(Err(why)), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 41a684abde..2c5759f202 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -267,7 +267,7 @@ pub struct Document { /// they were copied from instead of the current layer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub paste_remembers_layers: bool, - /// File → File Info (the title is [`Document::title`]). + /// File → File Info (its title is [`Document::info_title`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub metadata: DocMetadata, /// Effect → Document Raster Effects Settings besides the resolution diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 151365c5d2..908b649e4d 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -1,11 +1,15 @@ //! File → File Info: the document's descriptive metadata (author, description, keywords, rating, //! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The -//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved -//! before File Info existed load unchanged. +//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it +//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and +//! [`Document::info_title`] is the title File Info shows and exports write. Every field is +//! defaulted, so documents saved before File Info existed load unchanged. use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; +use crate::Document; + /// File Info → Copyright Status. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DocMetadata { + /// The title the file this document was opened from gives itself (an SVG's root ``, a + /// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of + /// [`Document::title`], the document's display name. `None`: the file gave none, so the + /// display name is the title. Left out of a saved document when unset, so other documents + /// save as they did. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, pub author: String, /// The author's position or job title. pub author_title: String, @@ -83,6 +94,16 @@ impl DocMetadata { } } +impl Document { + /// The title File Info shows and the files an export writes carry (PDF `Title` and XMP + /// `dc:title`, SVG `<title>`, PNG `Title`): the title the file this document was opened from + /// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the + /// default file name) is always [`Document::title`]. + pub fn info_title(&self) -> &str { + self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title) + } +} + /// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time` /// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock, /// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same @@ -191,6 +212,24 @@ mod tests { assert!(DocMetadata::default().png_text("").is_empty()); } + #[test] + fn the_info_title_is_the_files_own_title_else_the_display_name() { + let mut d = Document::new(10.0, 10.0); + d.title = "box.svg".into(); + assert_eq!(d.info_title(), "box.svg"); + d.metadata.title = Some("Access paths".into()); + assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths")); + d.metadata.title = Some(" ".into()); + assert_eq!(d.info_title(), "box.svg"); + // Saved only when set: other documents' metadata is as before, and the field comes back. + d.metadata.title = None; + assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title")); + d.metadata.title = Some("Access paths".into()); + let json = serde_json::to_string(&d.metadata).unwrap(); + assert_eq!(serde_json::from_str::<DocMetadata>(&json).unwrap().title.as_deref(), Some("Access paths")); + assert_eq!(serde_json::from_str::<DocMetadata>("{}").unwrap().title, None); + } + /// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative /// decimal integer; anything else leaves the clock to decide, so an export is never refused. #[test] diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index 36ccd7cdb0..9a59d0f2f8 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -1,7 +1,8 @@ //! File → File Info (`file.info`): the document title and its descriptive metadata (author, //! description, keywords, rating, copyright), with the read-only created and modified dates. The -//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info -//! it gives ([`read_pdf`]). +//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]), +//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or +//! `.ai` opens with the File Info it gives ([`read_pdf`]). use serde_json::{Map, Value, json}; use vectorcraft_doc::metadata::{MAX_RATING, iso8601}; @@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb pub(crate) fn info_json(d: &Document) -> Value { let m = &d.metadata; json!({ - "title": d.title, + "title": d.info_title(), "author": m.author, "authorTitle": m.author_title, "description": m.description, @@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { _ => return Err(bad(C, "params must be an object")), }; let d = &s.doc()?.doc; - let (mut title, mut m) = (d.title.clone(), d.metadata.clone()); + let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone()); for (k, v) in o { apply(&mut title, &mut m, k, v)?; } let d = &s.doc()?.doc; - if title != d.title || m != d.metadata { + // A title File Info changes becomes the document's own, as it always has (the tab and Save As + // offer it where the document has no file), and replaces the one its file gave. + let retitled = title != d.info_title(); + if retitled { + m.title = None; + } + if retitled || m != d.metadata { s.edit("File Info", |d, _| { - d.title = title; + if retitled { + d.title = title; + } d.metadata = m; Ok(()) })?; @@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { } /// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each -/// field that `file.info` would take (a value it refuses is left out), and the created and -/// modified dates. +/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`]) +/// as the File Info title, and the created and modified dates. pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain }); let fields = [ @@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let _ = apply(&mut title, m, k, &v); } } + m.title = f.title.as_deref().and_then(imported_title); m.created = f.created.or(m.created); m.modified = f.modified.or(m.modified); } @@ -141,6 +151,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> { Ok(s.to_string()) } +/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too) +/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters +/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the +/// Untitled-N name New gives (what a document that never had a title writes to its files). +pub(crate) fn imported_title(raw: &str) -> Option<String> { + let words: Vec<String> = + raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect(); + let title: String = words.join(" ").chars().take(MAX_TEXT).collect(); + let title = title.trim_end(); + super::fileio::is_own_title(title).then(|| title.to_string()) +} + /// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring /// case), empty ones dropped. fn keywords(v: &Value) -> Result<Vec<String>> { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index d5a20f47ec..329988cb34 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -143,6 +143,7 @@ pub(crate) use export_names::{ artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return, }; use export_names::{distinct_names_by, write_encoded}; +pub(crate) use formats::is_own_title; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0eeff7ab2e..0f91007515 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -592,7 +592,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?; // File Info as PNG text chunks. let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) { - vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title)) + vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title())) } else { bytes }; diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 290f211ad4..fe962a36ad 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -370,7 +370,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { let o = EpsOptions { region, origin, - title: doc.title.clone(), + title: doc.info_title().to_owned(), created, native: Some(native(&editing, if whole { None } else { board }, set.include_linked)), ..set.eps.clone() diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index aeaf962faf..d5137c8b1a 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -930,13 +930,20 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String { /// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep /// the stored title. pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool { - !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + is_own_title(doc.info_title()) +} + +/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives? +pub(crate) fn is_own_title(title: &str) -> bool { + !title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) } pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> { let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?; let mut d = doc.clone(); d.title = file_stem(path); + // A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it. + d.metadata.title = None; Some(d) } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index a197363b23..1fd3e10a9f 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -145,7 +145,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } } } - let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text }; + let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text }; let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?; for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..3040cfc43f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::effects::pixel::LoadedTexture; +use super::super::fileinfo::imported_title; use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, jxlread, psdread, read_file, readable_format_for_name}; @@ -550,6 +551,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } + // The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` + // or Info `Title`, which the readers put in File Info) is separate from the document's name. + // Restored File Info is preserved exactly; newly imported SVG candidates and a restored + // document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626). + if !restored && matches!(format.id, "svg" | "svgz") { + doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title); + } + let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title); + doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title)); // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { @@ -678,6 +688,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St if template { doc.template = false; doc.title = s.next_untitled(); + doc.metadata.title = None; } // A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start // closed, its top-level layers as its groups (#1475). Ours carry their own. @@ -711,7 +722,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St st.import_losses = losses; st.links_from = read_from; let opened = &s.documents()[index]; - let (title, file_info_title) = (opened.title(), opened.doc.title.clone()); + let (title, file_info_title) = (opened.title(), opened.doc.info_title().to_string()); Ok(super::merge( json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json(), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index ba05ec19e2..5d7783665b 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -731,7 +731,7 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res // With the artboards' background colors, as the pages draw them. let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?; let o = vectorcraft_eps::AiEditingOptions { - title: doc.title.clone(), + title: doc.info_title().to_owned(), created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), }; let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?; diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index 9e641a6e6a..d1c66d9d3c 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -645,7 +645,7 @@ fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator<Item = [u8; 3]> + '_ { /// The File Info text `s.metadata` lets into the files: `(keyword, text)`. fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> { - doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() + doc.metadata.png_text(doc.info_title()).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() } /// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata. diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9bad62a6c8..58832adf29 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -285,8 +285,8 @@ fn document_base(s: &Session) -> Value { json!({ "uid": st.uid, "title": st.title(), - // The File Info title (`file.info`), where `title` comes from the file name once the document has one. - "fileInfoTitle": d.title, + // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name. + "fileInfoTitle": d.info_title(), "path": st.path, "dirty": st.is_dirty(), "revision": st.revision, diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index c37cbfe90d..4d86a84b3c 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -161,7 +161,8 @@ fn an_exported_pdf_opens_with_its_file_info() { assert_eq!(back["description"], "A poster\nfor the fair"); assert_eq!(back["keywords"], json!(["fair", "Poster", "print"])); assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]); - assert_eq!(back["title"], "poster.pdf", "the title stays the file name"); + assert_eq!(back["title"], "Poster", "File Info shows the title the PDF carries"); + assert_eq!(s.doc().unwrap().title(), "poster.pdf", "the document stays named after the file"); assert_eq!(back["copyrightStatus"], "copyrighted"); assert_eq!(back["copyrightNotice"], "(c) 2026 Ada"); assert_eq!(back["copyrightUrl"], "https://example.com/rights"); @@ -277,6 +278,192 @@ fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() { assert_eq!(m.copyright_url, ""); } +/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose +/// catalog has a metadata stream holding an XMP `dc:title` of `xmp_title`. +fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec<u8> { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog}; + let first = first_extra(1); + let mut objects = vec![]; + let mut catalog = String::new(); + if let Some(t) = xmp_title { + let xmp = format!( + "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\ + <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\ + <dc:title><rdf:Alt><rdf:li xml:lang=\"de\">Zugangswege</rdf:li><rdf:li xml:lang=\"x-default\">{t}</rdf:li></rdf:Alt></dc:title>\ + </rdf:Description></rdf:RDF></x:xmpmeta>" + ); + objects.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())); + catalog = format!("/Metadata {first} 0 R "); + } + let info_at = first + objects.len(); + if let Some(i) = info { + objects.push(format!("<< {i} >>")); + } + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &refs, &catalog, None); + if info.is_none() { + return bytes; + } + // The trailer follows the cross-reference table, so a longer trailer moves no object. + String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes() +} + +/// A document called `name` with content `bytes` opened (as `document.open` with no path: the tab +/// shows the document's name), `s` holding it. +fn open_named(s: &mut Session, name: &str, bytes: &[u8]) -> Value { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap() +} + +/// The document's name (its tab) and its File Info title. +fn titles(s: &mut Session) -> [String; 2] { + let info = s.execute("file.info", &json!({})).unwrap(); + [s.doc().unwrap().title(), info["title"].as_str().unwrap().to_string()] +} + +/// What opening `bytes` as a file called `name` gives: the document's name and its File Info title. +fn opened(name: &str, bytes: &[u8]) -> [String; 2] { + let mut s = Session::new(); + let r = open_named(&mut s, name, bytes); + let t = titles(&mut s); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some(t[0].as_str()), Some(t[1].as_str())), "the open reply"); + assert_eq!(s.doc().unwrap().doc.title, t[0], "the tab is the document's title"); + t +} + +fn svg_titled(inner: &str) -> String { + format!( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip" + ) +} + +#[test] +fn import_puts_the_files_own_title_in_file_info_only() { + // SVG: the root's ``, its white space made single spaces (not a shape's tooltip), is the + // File Info title; the document keeps the file's name, with its extension. + assert_eq!(opened("box.svg", svg_titled("<title>\n Access\n paths ").as_bytes()), ["box.svg", "Access paths"]); + assert_eq!( + opened("/work/box.svg", svg_titled("Zugangswege Rechenzentrum – Prüfdiagramm").as_bytes()), + ["box.svg", "Zugangswege Rechenzentrum – Prüfdiagramm"] + ); + // PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone. + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.pdf", "Info title"]); + assert_eq!(opened("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.ai", "Info title"]); + // Compressed SVG too. + let svgz = |inner: &str| vectorcraft_svg::compress(&svg_titled(inner)); + assert_eq!(opened("box.svgz", &svgz("Access paths")), ["box.svgz", "Access paths"]); + // A file that gives none (or a blank one) gives File Info the document's name. + assert_eq!(opened("box.svg", svg_titled("").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("box.svg", svg_titled(" ").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, None)), ["plan.pdf", "plan.pdf"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), ["plan.pdf", "plan.pdf"]); + // The Untitled-N name New gives is no title (a document that never had one writes it). + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), ["plan.pdf", "plan.pdf"]); + // A template opens as a new untitled document, whatever title its file gave. + let [name, info] = opened("plan.ait", &pdf_titled(Some("/Title (Info title)"), None)); + assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}"); +} + +#[test] +fn imported_svg_titles_are_filtered_and_bounded() { + for (raw, expected) in [ + ("Untitled-2".to_string(), None), + ("\u{7f}".to_string(), None), + ("Re\u{7f}lease Plan".to_string(), Some("Release Plan".to_string())), + ("λ".repeat(65_537), Some("λ".repeat(65_536))), + ] { + let svg = svg_titled(&format!("{raw}")); + for format in ["svg", "svgz"] { + let name = format!("box.{format}"); + let bytes = if format == "svgz" { vectorcraft_svg::compress(&svg) } else { svg.as_bytes().to_vec() }; + assert_eq!(opened(&name, &bytes), [name.clone(), expected.clone().unwrap_or(name)], "{format}"); + } + } +} + +#[test] +fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() { + let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("box.svg"); + std::fs::write(&path, svg_titled("Access paths")).unwrap(); + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + // The default names of what the document exports are made from the file's name, not its title. + assert_eq!(s.doc().unwrap().doc.title, "box.svg"); + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "png"), "box.png"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn file_info_edits_a_files_own_title_and_the_document_follows() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The dialog sends back the title it was given: nothing changes (no undo step). + let before = s.doc().unwrap().revision; + let shown = s.execute("file.info", &json!({})).unwrap(); + s.execute("file.info", &shown).unwrap(); + assert_eq!(s.doc().unwrap().revision, before, "the title it gave is no edit"); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); + // Another title is the document's own, as ever, and the file's is gone. + let r = s.execute("file.info", &json!({"title": "Plan B"})).unwrap(); + assert_eq!(r["title"], "Plan B"); + assert_eq!(titles(&mut s), ["Plan B", "Plan B"]); + assert_eq!(s.doc().unwrap().doc.metadata.title, None); + // Undo brings the file's title back, and the name. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); +} + +#[test] +fn restored_file_info_title_keeps_authored_spaces() { + let mut s = session(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).metadata.title = Some("Release Plan".into()); + for format in ["vectorcraft", "svg", "pdf"] { + let bytes = b64(&s.execute("document.export", &json!({"format": format, "preserveEditing": true})).unwrap()); + let mut reopened = Session::new(); + let reply = open_named(&mut reopened, &format!("again.{format}"), &bytes); + assert_eq!(reply["restored"], format != "vectorcraft", "{format}"); + assert_eq!(reopened.doc().unwrap().doc.metadata.title.as_deref(), Some("Release Plan"), "{format}"); + assert_eq!(titles(&mut reopened)[1], "Release Plan", "{format}"); + } +} + +#[test] +fn the_files_own_title_is_kept_by_saves_and_written_by_exports() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The native file: its own title is the document's name, the File Info title survives. + let native = b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()); + let mut t = Session::new(); + open_named(&mut t, "other.vectorcraft", &native); + assert_eq!(titles(&mut t), ["box.svg", "Access paths"]); + // An SVG with the editing data it carries, reopened under another name. + let svg = s.execute("document.serialize", &json!({"format": "svg", "preserveEditing": true, "metadata": true})).unwrap()["text"] + .as_str() + .unwrap() + .to_string(); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + let mut u = Session::new(); + let r = open_named(&mut u, "again.svg", svg.as_bytes()); + assert_eq!(r["restored"], true); + assert_eq!(titles(&mut u), ["again.svg", "Access paths"]); + // PDF: the Info `Title` and the XMP `dc:title`, shown by the viewer. + for pdf in pdf_texts(&mut s) { + assert!(pdf.contains("/Title(Access paths)") && xmp_title(&pdf).contains("Access paths"), "{}", xmp_title(&pdf)); + assert!(pdf.contains("/ViewerPreferences<>"), "{pdf}"); + } + // PNG text. + let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap()); + assert!(crate::cmd::fileio::pngtext::text(&png).iter().any(|(k, v)| k == "Title" && v == "Access paths")); + // The default file names are made from the document's name. + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "pdf"), "box.pdf"); +} + #[test] fn png_exports_carry_text_chunks() { let mut s = session(); diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 5326e2f307..a517d516a6 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result emf::write(&ops, r, &doc.title), + Kind::Emf => emf::write(&ops, r, doc.info_title()), Kind::Wmf => wmf::write(&ops, r, &mut warnings), }; Ok(Output { bytes, warnings }) diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 5a19e62564..788cacea3a 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -53,7 +53,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let raised = for_long_pages(set, &pages)?; let set = raised.as_ref().unwrap_or(set); - let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); + // The file's Title is File Info's; the page information names the document, as its tab does. + let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned())); + let name = opts.title.as_deref().unwrap_or(&doc.title); // The time of writing (none on the web) dates the editing data and the page information. let now = vectorcraft_doc::metadata::output_time(); let created_at = opts.created.or(now); @@ -108,7 +110,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1)); let (m, r) = (boxes.media, boxes.bleed); - let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() }; + let info = if marks.page_info { crate::marks::page_info(doc, name, i, now) } else { String::new() }; let art = crate::marks::art(doc, &marks, &boxes, bleed, &info); // The page is the media box, y down from its top-left corner (like ours), in units of // `unit` points. diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 983a7dcea7..1cb0a45273 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -24,6 +24,8 @@ const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { + /// XMP `dc:title` (its `x-default` item, else its first), else Info `Title`. + pub title: Option, /// XMP `dc:creator` (its names joined with ", "), else Info `Author`. pub author: Option, /// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`. @@ -53,6 +55,7 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo { let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default(); let info = pdf.metadata(); let text = |b: &Option>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + f.title = f.title.or_else(|| text(&info.title)); f.author = f.author.or_else(|| text(&info.author)); f.description = f.description.or_else(|| text(&info.subject)); f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty())); @@ -90,6 +93,7 @@ fn xmp(packet: &[u8]) -> Option { let creators = get(DC, "creator"); let subjects = get(DC, "subject"); Some(FileInfo { + title: first(DC, "title"), author: (!creators.is_empty()).then(|| creators.join(", ")), description: first(DC, "description"), keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs index 7ce2f42f47..4c837ba67f 100644 --- a/crates/pdf/src/tests/file_info.rs +++ b/crates/pdf/src/tests/file_info.rs @@ -45,7 +45,7 @@ fn description(attrs: &str, body: &str) -> String { } const INFO: &str = "/Author (Grace Hopper) /Subject /Keywords (navy; compilers, , cobol) \ - /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)"; + /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Compiler manual)"; /// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00). const CREATED: i64 = 1_791_201_780; @@ -58,6 +58,7 @@ fn the_information_dictionary_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Compiler manual".into()), author: Some("Grace Hopper".into()), description: Some("Cata log".into()), keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]), @@ -79,7 +80,8 @@ fn the_information_dictionary_gives_file_info() { fn the_xmp_packet_gives_file_info() { let body = description( "xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"", - "Ada LovelaceCharles Babbage\ + "PlakatPoster\ + Ada LovelaceCharles Babbage\ Ein PlakatA poster\nfor the fair\ fair print \ (c) 2026 Ada\ @@ -90,6 +92,7 @@ fn the_xmp_packet_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Poster".into()), author: Some("Ada Lovelace, Charles Babbage".into()), description: Some("A poster\nfor the fair".into()), keywords: Some(vec!["fair".into(), "print".into()]), @@ -138,6 +141,13 @@ fn the_xmp_packet_wins_where_both_give_a_field() { assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60)); assert_eq!(f.description.as_deref(), Some("Cata log")); assert_eq!(f.modified, Some(MODIFIED)); + // The title is the packet's where it gives one (a blank `x-default` item leaves the next one), + // else the dictionary's. + assert_eq!(f.title.as_deref(), Some("Compiler manual")); + let body = + description("", " Manuel"); + let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Manuel")); } #[test] @@ -197,17 +207,29 @@ fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() { #[test] fn a_written_pdf_gives_its_file_info_back() { let mut d = Document::new(100.0, 100.0); + d.title = "Fair poster".into(); d.metadata.author = "Ada Lovelace".into(); d.metadata.description = "A poster\nfor the fair".into(); d.metadata.keywords = vec!["fair".into(), "print".into()]; let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); let f = file_info(&bytes, None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Fair poster")); assert_eq!(f.author.as_deref(), Some("Ada Lovelace")); assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair")); assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()])); assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED))); } +#[test] +fn a_written_pdf_carries_the_file_info_title_not_the_documents_name() { + let mut d = Document::new(100.0, 100.0); + d.title = "poster.svg".into(); + d.metadata.title = Some("Fair poster".into()); + let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); + assert_eq!(file_info(&bytes, None).unwrap().title.as_deref(), Some("Fair poster")); + assert!(!String::from_utf8_lossy(&bytes).contains("poster.svg")); +} + #[test] fn a_written_pdf_gives_its_copyright_back() { use vectorcraft_doc::CopyrightStatus; diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index eed8ecd335..8eb18626f3 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -188,7 +188,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: // An artboard exported on its own is named after it (#3544), else after the document. // A name that is blank once the characters XML can't hold are dropped falls back too. let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name)); - let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title)); + let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(doc.info_title())); if !title.is_empty() { out.push_str(&w.indent(1)); out.push_str(&format!("{title}{nl}")); diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 4f91d6c970..7c30f23f5a 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -88,7 +88,7 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { let info = &doc.metadata; let mut terms = vec![("format", "image/svg+xml".to_string())]; let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); - let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; + let fields = [("title", doc.info_title()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 1e852ee531..47fcfbaf14 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -38,7 +38,7 @@ pub(super) use importer::PT_PER_IN; use importer::SymbolMade; pub(super) use layers::DEFAULT_FONT_SIZE; pub(crate) use layers::SUBLAYER; -use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name}; +use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name, root_title}; pub(super) use prepass::Edits; use prepass::{Extras, labels, prepass}; pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE}; @@ -158,6 +158,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul let k = units.k; let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k); doc.units = units.unit; + // The file's own title, when it gives one, is the document's File Info title; the document's + // name stays what `Document::new` gave it, for the caller to name after the file. + doc.metadata.title = root_title(xml.root_element()); let mut im = Importer { doc, warnings, diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs index 15a77e37f0..a6d6a46b30 100644 --- a/crates/svg/src/import/layers.rs +++ b/crates/svg/src/import/layers.rs @@ -16,6 +16,16 @@ pub(super) fn root_layer_name(root: XNode) -> Option { root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) } +/// The title the file gives root element `root`: the text of its first `` child (the one +/// SVG 1.1 §5.4 and SVG 2 §5.4 give a document or element; a `<title>` inside a shape or group is +/// that element's tooltip, not the document's), its runs of white space made single spaces. `None` +/// without one, or when it is blank. +pub(super) fn root_title(root: XNode) -> Option<String> { + let title = root.children().find(|c| c.is_element() && c.tag_name().name() == "title" && c.tag_name().namespace() == Some(super::SVG_NS))?; + let text: Vec<&str> = title.descendants().filter_map(|n| n.text().filter(|_| n.is_text())).flat_map(str::split_whitespace).collect(); + (!text.is_empty()).then(|| text.join(" ")) +} + /// Is element `g` a `<g>` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or /// the label beside it) is its name. pub(super) fn marked_layer(g: XNode) -> bool { diff --git a/crates/svg/tests/it/title_control_chars.rs b/crates/svg/tests/it/title_control_chars.rs index 3318ed346f..2318eebc67 100644 --- a/crates/svg/tests/it/title_control_chars.rs +++ b/crates/svg/tests/it/title_control_chars.rs @@ -69,3 +69,29 @@ fn dublin_core_metadata_control_chars_are_filtered() { assert!(svg.contains("<dc:subject>kw</dc:subject>")); assert!(svg.contains("<dc:rights>(c) me</dc:rights>")); } + +/// The root's `<title>` is the File Info title of the imported document ([`Document::info_title`]); +/// its name stays what `Document::new` gave it, and a shape's `<title>` is that shape's tooltip. +#[test] +fn the_roots_title_is_the_file_info_title_not_the_documents_name() { + let svg = |inner: &str| { + format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">{inner}<rect width="5" height="5"><title>Tip"#) + }; + let d = import(&svg("\n Access\n paths ")).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", Some("Access paths"), "Access paths")); + for none in [svg(""), svg(" ")] { + let d = import(&none).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", None, "Untitled-1")); + } +} + +/// Exports write the File Info title, where the document has one, in place of its name. +#[test] +fn exports_write_the_file_info_title() { + let mut d = Document::new(200.0, 200.0); + d.title = "box.svg".into(); + d.metadata.title = Some("Access paths".into()); + let svg = export(&d, &ExportOptions { metadata: true, ..ExportOptions::default() }); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + assert!(!svg.contains("box.svg"), "{svg}"); +} diff --git a/docs/development.md b/docs/development.md index 2362fc6f1a..e7831adf1b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -> **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. +> **Last updated:** 2026-11-12 · **Change:** minor (clipboard failure helper follows its Linux/BSD and test callers; previously: Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending). > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. > **Last updated:** 2026-10-29 · **Change:** Linux clipboard: the Flatpak on GNOME copies and pastes text through the core Wayland protocol. @@ -351,6 +351,10 @@ parameters. Shipped code therefore never panics. Anything that can fail returns ### Enforced by lints +The clipboard timeout failure helper is compiled for Linux/BSD workers and unit tests; production +macOS reads synchronously. Keep helper target gates aligned with their callers instead of disabling +dead-code warnings across the workspace. + `Cargo.toml` denies these clippy lints for the whole workspace, and `cargo xtask ci` runs clippy with `-D warnings`. The gate targets the current stable Rust (`rust-version` in `Cargo.toml` is only the minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed @@ -508,6 +512,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Compile the clipboard failure helper only for Linux/BSD worker callers and unit tests; keep production macOS free of the unused helper without suppressing workspace dead-code warnings. | | 2026-11-06 | minor | Localisation: Hindi catalog, zero/one plurals, desktop and web font requirements; AI/MT draft, native review pending. | | 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. | | 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e2721d431b..89a048bd58 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -340,6 +340,9 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | +| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | +| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | | 2026-11-11 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | diff --git a/docs/gaps.md b/docs/gaps.md index c462fe19db..83afa40942 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -858,6 +858,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | An imported document's own title (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | diff --git a/docs/mcp.md b/docs/mcp.md index 4ac7a10930..baa2631f7b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5773,13 +5773,23 @@ They also write the `copyrightNotice` as `dc:rights`, the `copyrightUrl` as `xmp `copyrightStatus` as `xmpRights:Marked` (`True` for copyrighted, `False` for public domain, none for unknown) to the XMP packet. A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`, -`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`, -`description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP -packet (`dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, `dc:rights`, -`xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary (`Author`, -`Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one. A value that -`file.info` would refuse is left out, and the title is set as for any other open (the file name, or a new untitled -name for a template). +`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `title`, +`author`, `description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` +that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, +`dc:rights`, `xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary +(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give +one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its +root `<title>` element (not a shape's or group's). A title is read with its white space made single spaces and +without control characters, at most 65,536 characters, as `file.info` takes one. A value that `file.info` would +refuse is left out. A file that gives no title, a blank one or the `Untitled-N` name New gives leaves File Info with +the document's name as its title, and a template opens with a new untitled name. +The title a file gave is File Info's alone: the document's name, which is what its tab shows until it has a file and +what Save As and the default export names start from, stays the file's name with its extension (`box.svg`), or the +title a native file holds. File Info's `title` is the one the file gave, else the document's name. It is saved with the +document (a native file, and the editing data of an SVG, PDF, `.ai` or EPS that carries it), and the files an export +writes carry it (PDF `Title` and XMP `dc:title`, SVG `<title>` and Dublin Core, PNG text, EPS and `.ai` `%%Title`), +where the document has one. Changing the `title` with `file.info` sets the document's name, as it always did, and drops +the title the file gave. `document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as `fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..f28078ec33 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1538,7 +1538,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`). - Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged. - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). + - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root `<title>`. The title is File Info's alone (`DocMetadata::title`): the document's name stays the file's name with its extension, a file that gives no title leaves File Info with that name, and exports (PDF, SVG, PNG, EPS, `.ai`) and native saves carry it; changing the title in File Info sets the document's name as before (#3626). - PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`). - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). @@ -2069,6 +2069,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | | 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. |