mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
file.info takes and reports the title, author, author title, description, keywords, rating, copyright status, notice and URL in one undo step, plus the read-only created (file.new) and modified (every save to a file) dates; junk values are refused. Document.metadata (DocMetadata) round-trips natively. PDF export writes author, subject and keywords to the info dictionary and XMP, PNG export writes tEXt/iTXt chunks, SVG export writes <desc> and, with the metadata option, every field as Dublin Core. The File Info dialog (fileInfo, ui.fileInfoDialog) edits it with keyword chips and star ratings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
5.5 KiB
Rust
143 lines
5.5 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
|
|
//! SVG (with `metadata`), PDF and PNG writers carry it.
|
|
|
|
use serde_json::{Map, Value, json};
|
|
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
|
|
use vectorcraft_doc::{ColorMode, CopyrightStatus, DocMetadata, Document};
|
|
|
|
use super::*;
|
|
|
|
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.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.title.clone(), 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 {
|
|
s.edit("File Info", |d, _| {
|
|
d.title = title;
|
|
d.metadata = m;
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Ok(info_json(&s.doc()?.doc))
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
/// 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)
|
|
}
|