Files
vectorcraft/crates/engine/src/cmd/fileinfo.rs
T
Taha-Mahmoodi 99be3dca57 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.
2026-11-04 10:54:36 +04:30

190 lines
8.0 KiB
Rust

//! 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
//! 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};
use vectorcraft_doc::{ColorMode, CopyrightStatus, DocMetadata, Document};
use super::{Result, Session, bad};
const C: &str = "file.info";
/// Longest text field, in characters.
const MAX_TEXT: usize = 65_536;
/// Longest keyword, in characters.
const MAX_KEYWORD: usize = 256;
const MAX_KEYWORDS: usize = 1000;
/// Values `file.info` reports but doesn't take (fed back, they are ignored).
const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artboards", "objects"];
/// What `file.info` reports (and takes back).
pub(crate) fn info_json(d: &Document) -> Value {
let m = &d.metadata;
json!({
"title": d.info_title(),
"author": m.author,
"authorTitle": m.author_title,
"description": m.description,
"keywords": m.keywords,
"rating": m.rating,
"copyrightStatus": m.copyright_status.id(),
"copyrightNotice": m.copyright_notice,
"copyrightUrl": m.copyright_url,
"created": m.created.map(iso8601),
"modified": m.modified.map(iso8601),
"colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" },
"units": d.units.label(),
"artboards": d.artboards.len(),
"objects": d.node_count(),
})
}
pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
let empty = Map::new();
let o = match p {
Value::Null => &empty,
Value::Object(o) => o,
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
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;
// 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, _| {
if retitled {
d.title = title;
}
d.metadata = m;
Ok(())
})?;
}
Ok(info_json(&s.doc()?.doc))
}
/// 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), 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 = [
("author", f.author.map(Value::from)),
("description", f.description.map(Value::from)),
("keywords", f.keywords.map(Value::from)),
("copyrightStatus", status.map(|s| Value::from(s.id()))),
("copyrightNotice", f.copyright_notice.map(Value::from)),
("copyrightUrl", f.copyright_url.map(Value::from)),
];
// `apply` changes the field only when it takes the value.
let mut title = String::new();
for (k, v) in fields {
if let Some(v) = v {
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);
}
fn apply(title: &mut String, m: &mut DocMetadata, k: &str, v: &Value) -> Result<()> {
match k {
"title" => {
*title = text(k, v, false)?;
if title.is_empty() {
return Err(bad(C, "title can't be empty"));
}
}
"author" => m.author = text(k, v, false)?,
"authorTitle" => m.author_title = text(k, v, false)?,
"description" => m.description = text(k, v, true)?,
"keywords" => m.keywords = keywords(v)?,
"rating" => {
m.rating = v
.as_u64()
.filter(|r| *r <= u64::from(MAX_RATING))
.and_then(|r| u8::try_from(r).ok())
.ok_or_else(|| bad(C, format!("rating must be a whole number from 0 to {MAX_RATING}")))?
}
"copyrightStatus" => {
m.copyright_status =
v.as_str().and_then(CopyrightStatus::parse).ok_or_else(|| bad(C, "copyrightStatus must be unknown, copyrighted or publicDomain"))?
}
"copyrightNotice" => m.copyright_notice = text(k, v, true)?,
"copyrightUrl" => {
let url = text(k, v, false)?;
if url.chars().any(char::is_whitespace) || url.chars().count() > 2048 {
return Err(bad(C, "copyrightUrl must be a URL (no spaces, at most 2048 characters)"));
}
m.copyright_url = url;
}
k if READ_ONLY.contains(&k) => {}
_ => return Err(bad(C, format!("unknown File Info field `{k}`"))),
}
Ok(())
}
/// A trimmed text value (`null` clears it); line breaks only where `multiline`, no other control
/// characters.
fn text(k: &str, v: &Value, multiline: bool) -> Result<String> {
let s = match v {
Value::Null => "",
Value::String(s) => s.trim(),
_ => return Err(bad(C, format!("{k} must be text"))),
};
if s.chars().count() > MAX_TEXT {
return Err(bad(C, format!("{k} is too long (at most {MAX_TEXT} characters)")));
}
if s.chars().any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t'))) {
return Err(bad(C, format!("{k} has control characters{}", if multiline { "" } else { " or line breaks" })));
}
Ok(s.to_string())
}
/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too)
/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters
/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
/// Untitled-N name New gives (what a document that never had a title writes to its files).
pub(crate) fn imported_title(raw: &str) -> Option<String> {
let words: Vec<String> =
raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect();
let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
let title = title.trim_end();
super::fileio::is_own_title(title).then(|| title.to_string())
}
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
/// case), empty ones dropped.
fn keywords(v: &Value) -> Result<Vec<String>> {
let parts: Vec<String> = match v {
Value::Null => vec![],
Value::String(s) => s.split([',', ';']).map(str::to_string).collect(),
Value::Array(a) => a.iter().map(|x| text("keywords", x, false)).collect::<Result<_>>()?,
_ => return Err(bad(C, "keywords must be a list of words or a comma-separated string")),
};
let mut seen = std::collections::HashSet::new();
let mut out: Vec<String> = vec![];
for k in parts.iter().map(|k| k.trim()).filter(|k| !k.is_empty()) {
if k.chars().count() > MAX_KEYWORD || k.chars().any(char::is_control) {
return Err(bad(C, format!("keywords must be at most {MAX_KEYWORD} characters, without control characters")));
}
if seen.insert(k.to_lowercase()) {
out.push(k.to_string());
}
if out.len() > MAX_KEYWORDS {
return Err(bad(C, format!("at most {MAX_KEYWORDS} keywords")));
}
}
Ok(out)
}