From 27ab77377b02f23f104ab2826b257f302d12818b Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 4 Nov 2026 00:27:13 +0430
Subject: [PATCH] Fix #3626: an imported document takes the title the file
carries
An SVG's root
and a PDF's XMP dc:title (else Info Title) title the imported document; a file that gives none, or only the Untitled-N name, takes its file name without the extension. Native files and documents restored from editing data keep their own title.
---
crates/engine/src/cmd/fileinfo.rs | 12 +++
crates/engine/src/cmd/fileio.rs | 1 +
crates/engine/src/cmd/fileio/formats.rs | 7 +-
crates/engine/src/cmd/fileio/load.rs | 22 ++++--
crates/engine/src/cmd/fileio/tests.rs | 3 +-
.../engine/src/cmd/fileio/tests_dxfimport.rs | 6 +-
.../engine/src/cmd/fileio/tests_epsimport.rs | 2 +-
crates/engine/src/cmd/fileio/tests_svg.rs | 2 +-
crates/engine/src/tests/fileinfo.rs | 73 ++++++++++++++++++-
crates/pdf/src/file_info.rs | 4 +
crates/pdf/src/tests/file_info.rs | 16 +++-
crates/svg/src/import.rs | 7 +-
crates/svg/src/import/layers.rs | 10 +++
docs/file-format-parity.md | 3 +-
docs/gaps.md | 3 +-
docs/mcp.md | 17 +++--
docs/target-app-parity.md | 5 +-
17 files changed, 167 insertions(+), 26 deletions(-)
diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs
index 36ccd7cdb0..3a37098b48 100644
--- a/crates/engine/src/cmd/fileinfo.rs
+++ b/crates/engine/src/cmd/fileinfo.rs
@@ -141,6 +141,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result {
Ok(s.to_string())
}
+/// The title `raw`, read from a file, makes a document: 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 76f408d221..cbc8506fca 100644
--- a/crates/engine/src/cmd/fileio.rs
+++ b/crates/engine/src/cmd/fileio.rs
@@ -125,6 +125,7 @@ pub use commands::specs;
pub use export_names::artboard_file_names;
pub(crate) use export_names::{default_name, merge, unique_file_names, 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, export_extensions, extension,
diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs
index 09e8de8294..30778f6a6e 100644
--- a/crates/engine/src/cmd/fileio/formats.rs
+++ b/crates/engine/src/cmd/fileio/formats.rs
@@ -778,7 +778,12 @@ 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.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 {
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index a318616d54..8a646a8e91 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -7,9 +7,10 @@ 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, psdread, read_file, readable_format_for_name};
+use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
@@ -392,10 +393,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
};
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
+ let mut pdf_title = None;
if matches!(format.id, "pdf" | "ai" | "ait")
&& !restored
&& let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref())
{
+ pdf_title = info.title.clone();
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames (the threaded type of a restored
@@ -412,11 +415,18 @@ 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);
}
- // 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() {
- doc.title = title;
- }
+ // The document is titled as the file titles it: a native document keeps its own title, one
+ // restored from the editing data a file carries and an SVG keep the title they hold (the SVG's
+ // ``), a PDF, `.ai` or `.ait` takes its XMP `dc:title` or Info `Title`. Without one the
+ // document takes the file name without its extension, as an export does (#3626). The tab shows
+ // the file name once the document has a path.
+ let own = match format.id {
+ "vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()),
+ _ if restored || format.id == "svg" => imported_title(&doc.title),
+ "pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title),
+ _ => None,
+ };
+ doc.title = own.unwrap_or_else(|| file_stem(&title));
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs
index 69158c04b8..d3714e82e8 100644
--- a/crates/engine/src/cmd/fileio/tests.rs
+++ b/crates/engine/src/cmd/fileio/tests.rs
@@ -67,7 +67,8 @@ fn opens_every_readable_format() {
] {
let r = open(&mut s, name, &bytes);
assert_eq!(r["format"], format, "{name}");
- assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
+ // The document that wrote the file never had a title of its own, so the file gives none and the name (without its extension) titles it.
+ assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { "a" }, "{name}");
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
}
for (name, kind) in [
diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
index ec7b295c23..c7de9d9e16 100644
--- a/crates/engine/src/cmd/fileio/tests_dxfimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
@@ -120,7 +120,11 @@ const MM: f64 = 72.0 / 25.4;
fn document_open_reads_a_dxf_drawing() {
let mut s = Session::new();
let r = open(&mut s, &plan(), json!({})).unwrap();
- assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf")));
+ assert_eq!(
+ (r["format"].as_str(), r["title"].as_str()),
+ (Some("dxf"), Some("plan")),
+ "a file that carries no title opens as its name without the extension"
+ );
assert_eq!(layer_names(&s), ["Walls", "Notes"]);
let st = s.doc().unwrap();
// Millimetres at 1:1, the artboard the art's size.
diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs
index 8c4141902c..0e9b8e805f 100644
--- a/crates/engine/src/cmd/fileio/tests_epsimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs
@@ -64,7 +64,7 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() {
// The page is the bounding box: the art's bounds.
assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points");
assert_eq!(objects(&s), 1);
- assert_eq!(s.doc().unwrap().title(), "plain.eps");
+ assert_eq!(s.doc().unwrap().title(), "plain");
}
/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when
diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs
index 73ee604a98..ff3a73432c 100644
--- a/crates/engine/src/cmd/fileio/tests_svg.rs
+++ b/crates/engine/src/cmd/fileio/tests_svg.rs
@@ -138,7 +138,7 @@ fn preserve_editing_reopens_the_native_document() {
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 2, "both artboards came back");
assert_eq!(doc.artboards[1].name, "Back");
- assert_eq!(doc.title, "both.svg");
+ assert_eq!(doc.title, "both");
// Without it the SVG is imported (one artboard).
let text = svg(&mut s, json!({}));
s.execute("document.open", &json!({"name": "plain.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap();
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index c37cbfe90d..905cf910f3 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -161,7 +161,7 @@ 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", "the title is the one the PDF carries, not its file name");
assert_eq!(back["copyrightStatus"], "copyrighted");
assert_eq!(back["copyrightNotice"], "(c) 2026 Ada");
assert_eq!(back["copyrightUrl"], "https://example.com/rights");
@@ -277,6 +277,77 @@ 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()
+}
+
+/// The title `document.open` gives a file called `name` with content `bytes`.
+fn opened_title(name: &str, bytes: &[u8]) -> String {
+ let mut s = Session::new();
+ let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
+ assert_eq!(r["fileInfoTitle"], s.doc().unwrap().doc.title.as_str());
+ s.doc().unwrap().doc.title.clone()
+}
+
+#[test]
+fn import_uses_the_files_own_title() {
+ // SVG: the root's ``, its white space made single spaces; not a shape's tooltip.
+ let svg = |inner: &str| {
+ format!(
+ ""
+ )
+ };
+ assert_eq!(opened_title("box.svg", svg("\n Access\n paths ").as_bytes()), "Access paths");
+ assert_eq!(
+ opened_title("box.svg", svg("Zugangswege Rechenzentrum – Prüfdiagramm").as_bytes()),
+ "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_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), "XMP title");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(None, Some("XMP title"))), "XMP title");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
+ assert_eq!(opened_title("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
+ // A file that gives none (or a blank one): its name without the extension, in a folder too.
+ assert_eq!(opened_title("box.svg", svg("").as_bytes()), "box");
+ assert_eq!(opened_title("/work/box.svg", svg("").as_bytes()), "box");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(None, None)), "plan");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), "plan");
+ // The Untitled-N name New gives is no title (a document that never had one writes it).
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), "plan");
+ // A native file keeps the title it has.
+ let mut s = session();
+ let native = vectorcraft_format::base64_encode(&b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()));
+ let mut t = Session::new();
+ t.execute("document.open", &json!({"name": "other.vectorcraft", "dataBase64": native})).unwrap();
+ assert_eq!(t.doc().unwrap().doc.title, "Poster");
+}
+
#[test]
fn png_exports_carry_text_chunks() {
let mut s = session();
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..f7c5d694b6 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,11 +207,13 @@ 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()]));
diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs
index 11795b83fa..aa625de695 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;
pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE};
use prepass::{labels, prepass};
@@ -142,6 +142,11 @@ 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; else the document keeps the placeholder name
+ // `Document::new` gave it, for the caller to name after the file.
+ if let Some(title) = root_title(xml.root_element()) {
+ doc.title = title;
+ }
let mut im = Importer {
doc,
warnings,
diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs
index 04e3887945..6115d2e445 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 `` 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 {
+ 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 `` 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/docs/file-format-parity.md b/docs/file-format-parity.md
index 6f02703ca5..ba1d1738e4 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-01 · **Change:** minor (native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: native format: dynamic symbols, #4938) · **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
@@ -161,6 +161,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | An imported document takes the title its file carries: an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`; a file that gives none opens with its file name without the extension (#3626). |
| 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. |
| 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out |
| 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) |
diff --git a/docs/gaps.md b/docs/gaps.md
index 8b707fd9f6..016703c688 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -732,6 +732,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 takes the title its file carries (an SVG's root ``, a PDF's XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); no scored gap or estimate reassessed. |
| 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. |
| 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. |
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. |
diff --git a/docs/mcp.md b/docs/mcp.md
index 0ca4e32353..4911590619 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -3908,13 +3908,16 @@ 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 `` 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 file that gives no title, a blank
+one or the `Untitled-N` name New gives, opens with its file name without the extension (`box.svg` opens as `box`), and a native file keeps its own
+title. A value that `file.info` would refuse is left out, and a template opens with a new untitled name.
`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 b4370a3a0c..b544fdf895 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -1172,7 +1172,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 ``; a file that gives no title opens with its file name without the extension, and a native file keeps its own title (#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`).
@@ -1565,6 +1565,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | File Info on open: an imported document takes the title its file carries (an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); scores and estimates unchanged. |
| 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. |
| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. |
| 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. |