M4.55: File Info with author, description, keyword chips, rating and copyright, written to SVG, PDF and PNG

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>
This commit is contained in:
bflatastic
2026-10-05 09:55:47 +07:00
co-authored by Claude Opus 5.5
parent a908ba0190
commit 6a446b4fa3
26 changed files with 913 additions and 60 deletions
+2 -20
View File
@@ -158,9 +158,9 @@ pub fn specs() -> Vec<CommandSpec> {
"File Info…",
["File"],
Some("Cmd+Alt+Shift+I"),
"{title?} set the document title; always → {title, colorMode, units, artboards, objects}",
"{title?, author?, authorTitle?, description?, keywords?: [string…]|\"a, b\" (each once), rating?: 0–5, copyrightStatus?: \"unknown\"|\"copyrighted\"|\"publicDomain\", copyrightNotice?, copyrightUrl?} set the File Info in one undo step (SVG with metadata, PDF and PNG exports carry it); no params → {title, author, authorTitle, description, keywords, rating, copyrightStatus, copyrightNotice, copyrightUrl, created, modified (ISO 8601 UTC or null; read-only), colorMode, units, artboards, objects}",
has_doc,
file_info
super::fileinfo::file_info
),
]
}
@@ -484,21 +484,3 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
}
}
}
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(t) = str_param(p, "title") {
let t = t.to_string();
s.edit("File Info", |d, _| {
d.title = t;
Ok(())
})?;
}
let d = &s.doc()?.doc;
Ok(json!({
"title": d.title,
"colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" },
"units": d.units.label(),
"artboards": d.artboards.len(),
"objects": d.node_count(),
}))
}
+142
View File
@@ -0,0 +1,142 @@
//! 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)
}
+8 -1
View File
@@ -272,7 +272,14 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
check_format_size(f, region.width() * scale, region.height() * scale)?;
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
enc.files.push((Some(b), renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?));
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
// File Info as PNG text chunks.
let bytes = if format == RasterFormat::Png {
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
} else {
bytes
};
enc.files.push((Some(b), bytes));
}
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
+2 -1
View File
@@ -18,6 +18,7 @@ mod export;
mod load;
pub mod pdf;
mod pdfimport;
pub mod pngtext;
pub mod ppi;
mod save;
mod svg;
@@ -30,7 +31,7 @@ use load::err;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image};
pub use pdfimport::{LoadOptions, page_document};
pub use save::{save_encoding, save_format};
pub use save::{save_encoding, save_format, stamp_save_dates};
pub use svg::options_map as svg_options;
use super::*;
+47
View File
@@ -0,0 +1,47 @@
//! PNG text chunks (`tEXt`, uncompressed `iTXt`) read back; the PNG writer adds them
//! ([`vectorcraft_render::encode::png::with_text`]).
/// The `tEXt` and uncompressed `iTXt` entries of a PNG before its image data: (keyword, text).
pub fn text(png: &[u8]) -> Vec<(String, String)> {
if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
return vec![];
}
super::ppi::png_chunks(png)
.take_while(|(ty, _)| *ty != b"IDAT")
.filter_map(|(ty, data)| {
let nul = data.iter().position(|b| *b == 0)?;
let keyword: String = data.get(..nul)?.iter().map(|b| char::from(*b)).collect();
let rest = data.get(nul + 1..)?;
let text = match ty {
b"tEXt" => rest.iter().map(|b| char::from(*b)).collect(),
// Compression flag 0, method, then the language tag and translated keyword.
b"iTXt" if rest.first() == Some(&0) => {
let mut parts = rest.get(2..)?.splitn(3, |b| *b == 0);
let (_, _, text) = (parts.next()?, parts.next()?, parts.next()?);
String::from_utf8_lossy(text).into_owned()
}
_ => return None,
};
Some((keyword, text))
})
.collect()
}
#[cfg(test)]
mod tests {
use vectorcraft_render::encode::png::with_text;
use super::*;
#[test]
fn text_chunks_round_trip_and_stay_valid() {
let png = vectorcraft_render::Rendered { width: 2, height: 1, pixels: vec![255; 8] }.to_png().unwrap();
let out = with_text(png.clone(), &[("Title", "Café".into()), ("Author", "Łukasz".into()), ("bad\u{1}", "x".into())]);
assert_eq!(text(&out), [("Title".to_string(), "Café".to_string()), ("Author".into(), "Łukasz".into())]);
// Still a PNG every decoder reads (the CRCs check out).
let img = image::load_from_memory(&out).unwrap();
assert_eq!((img.width(), img.height()), (2, 1));
assert_eq!(with_text(b"not a png".to_vec(), &[("Title", "x".into())]), b"not a png");
assert_eq!(with_text(png.clone(), &[]), png);
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ fn per_metre(ppm: u32) -> f64 {
}
/// PNG chunks after the signature: (type, data).
fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
pub(super) fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
let mut at = 8;
std::iter::from_fn(move || {
let len = be32(b, at)? as usize;
+12
View File
@@ -39,10 +39,22 @@ pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, V
Ok((enc, opts))
}
/// File Info's dates for a save to a file: the modified date (and the created date, when it has
/// none) become now. Not an undo step; the web build, without a clock, leaves them.
pub fn stamp_save_dates(st: &mut DocState) {
let Some(now) = vectorcraft_doc::metadata::now_unix() else { return };
let d = std::sync::Arc::make_mut(&mut st.doc);
d.metadata.created.get_or_insert(now);
d.metadata.modified = Some(now);
}
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.save";
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
let f = save_format(str_param(p, "format"), path.as_deref()).map_err(|e| bad(C, e))?;
if path.is_some() {
stamp_save_dates(s.doc_mut()?);
}
let st = s.doc()?;
let (enc, opts) = save_encoding(st, f, p)?;
let Some(path) = path else {
+6 -1
View File
@@ -54,7 +54,12 @@ pub const OPTIONS: &[FormatOption] = &[
default: "false",
description: "embed the native document in <metadata> so VectorCraft reopens the SVG with nothing lost",
},
FormatOption { name: "metadata", ty: "boolean", default: "false", description: "write <metadata> with the title and format (Dublin Core)" },
FormatOption {
name: "metadata",
ty: "boolean",
default: "false",
description: "write <metadata> with the title, format and File Info (Dublin Core)",
},
FormatOption {
name: "fewerTspans",
ty: "boolean",
+1
View File
@@ -17,6 +17,7 @@ mod draw2;
mod edit;
mod effectcmd;
pub mod expand;
pub(crate) mod fileinfo;
pub mod fileio;
pub mod flatten;
mod fonts;
+1
View File
@@ -558,6 +558,7 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
Some(t) => t.to_string(),
None => s.next_untitled(),
};
d.metadata.created = vectorcraft_doc::metadata::now_unix();
let i = s.add_document(d, None);
// Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both
// are views of the whole app, so Default leaves them as they are.