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,
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 ``, 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::(&json).unwrap().title.as_deref(), Some("Access paths"));
+ assert_eq!(serde_json::from_str::("{}").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 {
_ => 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 {
}
/// 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 {
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 {
+ let words: Vec =
+ raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::()).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> {
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 {
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 {
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 {
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
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 ``, 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 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 + '_ {
/// 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 {
+ 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!(
+ "\
+ \
+ Zugangswege{t}\
+ "
+ );
+ 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!(
+ ""
+ )
+}
+
+#[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("\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