mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 23:05:05 +00:00
Fix #3626: the imported title goes into File Info only; the display name is unchanged
An SVG's root <title> and a PDF's, .ai's or .ait's XMP dc:title (else Info Title) are now DocMetadata::title, the File Info title, not Document::title. Document::title stays what it was on main: the file's name with its extension (a native file keeps its own), so the tab, the window title, Save As, the default export names and Save for Web are unchanged. - Document::info_title() is metadata.title else the document's title. file.info reports and compares it, document.inspect and the document.open reply report it as fileInfoTitle, and the writers of a title (PDF Title and XMP dc:title, SVG <title> and Dublin Core, PNG text, EPS, .ai and EMF) and has_own_title (DisplayDocTitle) use it. The PDF page-information mark and the default file names still use the document's name. - Changing the title in File Info sets the document's title as it always did and drops the one the file gave; the unchanged title the dialog sends back is no edit. - metadata.title is saved in the native format only when set (older versions ignore it) and is read back from the editing data of an SVG, PDF, .ai or EPS; a template opens untitled. - Reverts the file-stem title, the stem_title cleaning and the svgz branch that only existed to make Document::title a stem, and the five assertions that change had made.
This commit is contained in:
@@ -253,7 +253,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
|
||||
|
||||
@@ -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 `<title>`, 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]
|
||||
|
||||
@@ -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,29 +151,16 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> {
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
/// The title `raw`, read from a file, makes a document: runs of white space (line breaks too)
|
||||
/// 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 title = cleaned_title(raw);
|
||||
super::fileio::is_own_title(&title).then_some(title)
|
||||
}
|
||||
|
||||
/// `raw` as `file.info` takes a title (see [`imported_title`]), empty when nothing is left. An
|
||||
/// Untitled-N name stays.
|
||||
fn cleaned_title(raw: &str) -> 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();
|
||||
title.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// The title a document without one of its own takes from its file name's `stem`: cleaned as an
|
||||
/// imported title is, so `file.info` takes it, and the Untitled-1 name a new document starts
|
||||
/// with when nothing is left of it.
|
||||
pub(crate) fn stem_title(stem: &str) -> String {
|
||||
Some(cleaned_title(stem)).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled-1".to_string())
|
||||
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
|
||||
|
||||
@@ -589,7 +589,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
|
||||
};
|
||||
|
||||
@@ -326,7 +326,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 saving { None } else { board }, set.include_linked)),
|
||||
..set.eps.clone()
|
||||
|
||||
@@ -778,7 +778,7 @@ 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 {
|
||||
is_own_title(&doc.title)
|
||||
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?
|
||||
@@ -790,6 +790,8 @@ pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, fo
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,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);
|
||||
|
||||
@@ -7,10 +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, stem_title};
|
||||
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, file_stem, format, psdread, read_file, readable_format_for_name};
|
||||
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name};
|
||||
use crate::cmd::links::Resolved;
|
||||
use crate::{EngineError, Prefs};
|
||||
|
||||
@@ -391,12 +391,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
};
|
||||
// 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
|
||||
@@ -413,18 +411,18 @@ 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 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
|
||||
// `<title>`), 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 || matches!(format.id, "svg" | "svgz") => imported_title(&doc.title),
|
||||
"pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title),
|
||||
_ => None,
|
||||
};
|
||||
doc.title = own.unwrap_or_else(|| stem_title(&file_stem(&title)));
|
||||
// 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; the one a native document holds) is the
|
||||
// File Info title, cleaned as `file.info` takes one. A file restored from the editing data it
|
||||
// carries gives the title that document had, unless that is the file's name. The document's own
|
||||
// name stays what it has always been: the file's name (#3626).
|
||||
let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
|
||||
doc.metadata.title = doc.metadata.title.as_deref().or(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() {
|
||||
doc.title = title;
|
||||
}
|
||||
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
|
||||
}
|
||||
@@ -540,6 +538,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.
|
||||
@@ -568,7 +567,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
|
||||
st.imported_from = source_path;
|
||||
st.import_losses = losses;
|
||||
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(),
|
||||
|
||||
@@ -674,7 +674,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))?;
|
||||
|
||||
@@ -67,8 +67,7 @@ fn opens_every_readable_format() {
|
||||
] {
|
||||
let r = open(&mut s, name, &bytes);
|
||||
assert_eq!(r["format"], format, "{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_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
|
||||
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
|
||||
}
|
||||
for (name, kind) in [
|
||||
|
||||
@@ -120,11 +120,7 @@ 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")),
|
||||
"a file that carries no title opens as its name without the extension"
|
||||
);
|
||||
assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf")));
|
||||
assert_eq!(layer_names(&s), ["Walls", "Notes"]);
|
||||
let st = s.doc().unwrap();
|
||||
// Millimetres at 1:1, the artboard the art's size.
|
||||
|
||||
@@ -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");
|
||||
assert_eq!(s.doc().unwrap().title(), "plain.eps");
|
||||
}
|
||||
|
||||
/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when
|
||||
|
||||
@@ -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");
|
||||
assert_eq!(doc.title, "both.svg");
|
||||
// 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();
|
||||
|
||||
@@ -644,7 +644,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.
|
||||
|
||||
@@ -270,8 +270,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,
|
||||
|
||||
@@ -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", "the title is the one the PDF carries, not its 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");
|
||||
@@ -307,67 +308,129 @@ fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec<u8> {
|
||||
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 {
|
||||
/// 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 = 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()
|
||||
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</title></rect></svg>"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_uses_the_files_own_title() {
|
||||
// SVG: the root's `<title>`, its white space made single spaces; not a shape's tooltip.
|
||||
let svg = |inner: &str| {
|
||||
format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip</title></rect></svg>"
|
||||
)
|
||||
};
|
||||
assert_eq!(opened_title("box.svg", svg("<title>\n Access\n paths </title>").as_bytes()), "Access paths");
|
||||
fn import_puts_the_files_own_title_in_file_info_only() {
|
||||
// SVG: the root's `<title>`, 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 </title>").as_bytes()), ["box.svg", "Access paths"]);
|
||||
assert_eq!(
|
||||
opened_title("box.svg", svg("<title>Zugangswege Rechenzentrum – Prüfdiagramm</title>").as_bytes()),
|
||||
"Zugangswege Rechenzentrum – Prüfdiagramm"
|
||||
opened("/work/box.svg", svg_titled("<title>Zugangswege Rechenzentrum – Prüfdiagramm</title>").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_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("<title> </title>").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");
|
||||
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("<title>Access paths</title>")), ["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("<title> </title>").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_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()));
|
||||
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 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("<title>Access paths</title>")).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("<title>Access paths</title>").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 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("<title>Access paths</title>").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();
|
||||
t.execute("document.open", &json!({"name": "other.vectorcraft", "dataBase64": native})).unwrap();
|
||||
assert_eq!(t.doc().unwrap().doc.title, "Poster");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_svg_keeps_its_title() {
|
||||
let svgz = |inner: &str| {
|
||||
vectorcraft_svg::compress(&format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"/></svg>"
|
||||
))
|
||||
};
|
||||
assert_eq!(opened_title("box.svgz", &svgz("<title>Access paths</title>")), "Access paths");
|
||||
assert_eq!(opened_title("box.svgz", &svgz("")), "box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_name_title_is_cleaned_like_an_imported_one() {
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\"><rect width=\"5\" height=\"5\"/></svg>";
|
||||
// A name `file.info` would reject (white space only) or that keeps control characters.
|
||||
assert_eq!(opened_title(" .svg", svg), "Untitled-1");
|
||||
assert_eq!(opened_title("\u{1}\u{7f}.svg", svg), "Untitled-1");
|
||||
assert_eq!(opened_title("bo\u{1}x two\t.svg", svg), "box two");
|
||||
// The Untitled-N name stays the stem.
|
||||
assert_eq!(opened_title("Untitled-7.svg", svg), "Untitled-7");
|
||||
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("<title>Access paths</title>") && svg.contains("<dc:title>Access paths</dc:title>"), "{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<</DisplayDocTitle true>>"), "{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]
|
||||
|
||||
@@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result<Output, String
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
let (ops, mut warnings) = scene::Scene::new(doc, r, kind == Kind::Emf).run();
|
||||
let bytes = match kind {
|
||||
Kind::Emf => 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 })
|
||||
|
||||
@@ -48,7 +48,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
let pages = pages(doc, opts)?;
|
||||
|
||||
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);
|
||||
@@ -86,7 +88,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.
|
||||
|
||||
@@ -220,6 +220,16 @@ fn a_written_pdf_gives_its_file_info_back() {
|
||||
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;
|
||||
|
||||
@@ -186,7 +186,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>{title}</title>{nl}"));
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -142,11 +142,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; 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;
|
||||
}
|
||||
// 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,
|
||||
|
||||
@@ -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</title></rect></svg>"#)
|
||||
};
|
||||
let d = import(&svg("<title>\n Access\n paths </title>")).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("<title> </title>")] {
|
||||
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("<title>Access paths</title>") && svg.contains("<dc:title>Access paths</dc:title>"), "{svg}");
|
||||
assert!(!svg.contains("box.svg"), "{svg}");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; 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
|
||||
@@ -166,7 +166,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 `<title>`, 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-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root `<title>`, 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-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) |
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -733,7 +733,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 `<title>`, 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-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-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. |
|
||||
|
||||
+10
-3
@@ -3940,9 +3940,16 @@ that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or
|
||||
(`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 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.
|
||||
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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -1180,7 +1180,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 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>`; a file that gives no title opens with its file name without the extension, and a native file keeps its own title (#3626).
|
||||
- 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`).
|
||||
@@ -1573,7 +1573,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 `<title>`, 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 | 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-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. |
|
||||
|
||||
Reference in New Issue
Block a user