diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d5c6d5e3..c4687529 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -10,6 +10,7 @@ pub mod graph; pub mod hit; pub mod live; pub mod marks; +pub mod metadata; pub mod node; pub mod overprint; pub mod pattern; @@ -45,6 +46,7 @@ pub use appearance::{ pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; +pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; pub use node::{ImageMap, ObjectAttributes}; @@ -476,6 +478,9 @@ 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`]). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub metadata: DocMetadata, } fn ppi72() -> f64 { @@ -533,6 +538,7 @@ impl Document { spot_use_lab: true, setup: DocSetup::default(), paste_remembers_layers: false, + metadata: DocMetadata::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs new file mode 100644 index 00000000..2d3519fc --- /dev/null +++ b/crates/doc/src/metadata.rs @@ -0,0 +1,151 @@ +//! 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. + +use serde::{Deserialize, Serialize}; + +/// File Info → Copyright Status. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CopyrightStatus { + #[default] + Unknown, + Copyrighted, + PublicDomain, +} + +impl CopyrightStatus { + pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain]; + pub fn id(self) -> &'static str { + match self { + CopyrightStatus::Unknown => "unknown", + CopyrightStatus::Copyrighted => "copyrighted", + CopyrightStatus::PublicDomain => "publicDomain", + } + } + pub fn label(self) -> &'static str { + match self { + CopyrightStatus::Unknown => "Unknown", + CopyrightStatus::Copyrighted => "Copyrighted", + CopyrightStatus::PublicDomain => "Public Domain", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s) || c.label().eq_ignore_ascii_case(s)) + } +} + +/// Highest File Info rating (stars). +pub const MAX_RATING: u8 = 5; + +/// File → File Info. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DocMetadata { + pub author: String, + /// The author's position or job title. + pub author_title: String, + pub description: String, + /// Keywords, each once, in the order they were added. + pub keywords: Vec, + /// 0 (unrated) to [`MAX_RATING`] stars. + pub rating: u8, + pub copyright_status: CopyrightStatus, + pub copyright_notice: String, + /// Where to read more about the copyright. + pub copyright_url: String, + /// When the document was created, as Unix seconds (UTC). + pub created: Option, + /// When the document was last saved, as Unix seconds (UTC). + pub modified: Option, +} + +impl DocMetadata { + /// The PNG text entries (registered keywords where one fits) for a document titled `title`: + /// only the non-empty ones. + pub fn png_text<'a>(&'a self, title: &'a str) -> Vec<(&'static str, std::borrow::Cow<'a, str>)> { + let mut out: Vec<(&'static str, std::borrow::Cow<'a, str>)> = vec![]; + let mut push = |k: &'static str, v: std::borrow::Cow<'a, str>| { + if !v.trim().is_empty() { + out.push((k, v)); + } + }; + push("Title", title.into()); + push("Author", self.author.as_str().into()); + push("Description", self.description.as_str().into()); + push("Keywords", self.keywords.join(", ").into()); + push("Copyright", self.copyright_notice.as_str().into()); + push("Copyright URL", self.copyright_url.as_str().into()); + push("Creation Time", self.created.map(iso8601).unwrap_or_default().into()); + out + } +} + +/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build). +#[cfg(not(target_arch = "wasm32"))] +pub fn now_unix() -> Option { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()) +} +#[cfg(target_arch = "wasm32")] +pub fn now_unix() -> Option { + None +} + +/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic +/// Gregorian; years clamped to 0–9999). +pub fn civil(t: i64) -> [i64; 6] { + // Clamp first so every step stays in range for any input. + let t = t.clamp(-62_167_219_200, 253_402_300_799); + let days = t.div_euclid(86_400); + let secs = t.rem_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + [year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60] +} + +/// Unix seconds → `2026-10-05T14:03:00Z`. +pub fn iso8601(t: i64) -> String { + let [y, mo, d, h, mi, s] = civil(t); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates_and_statuses() { + assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z"); + assert_eq!(civil(i64::MIN)[0], 0); + assert_eq!(civil(i64::MAX)[0], 9999); + assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain)); + assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain)); + assert_eq!(CopyrightStatus::parse("maybe"), None); + } + + #[test] + fn png_text_skips_empty_entries() { + let m = DocMetadata { author: "Ada".into(), keywords: vec!["a".into(), "b".into()], created: Some(0), ..Default::default() }; + let t: Vec<(&str, String)> = m.png_text("Poster").into_iter().map(|(k, v)| (k, v.into_owned())).collect(); + assert_eq!( + t, + [ + ("Title", "Poster".to_string()), + ("Author", "Ada".into()), + ("Keywords", "a, b".into()), + ("Creation Time", "1970-01-01T00:00:00Z".into()) + ] + ); + assert!(DocMetadata::default().png_text("").is_empty()); + } +} diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 49614fc5..4126557d 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -158,9 +158,9 @@ pub fn specs() -> Vec { "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 { - 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(), - })) -} diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs new file mode 100644 index 00000000..c26b07b2 --- /dev/null +++ b/crates/engine/src/cmd/fileinfo.rs @@ -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 { + 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 { + 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> { + let parts: Vec = 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::>()?, + _ => 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 = 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) +} diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 3d03d3e7..0440776a 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -272,7 +272,14 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { 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")); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index dc8d490c..059e549d 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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::*; diff --git a/crates/engine/src/cmd/fileio/pngtext.rs b/crates/engine/src/cmd/fileio/pngtext.rs new file mode 100644 index 00000000..94a30d13 --- /dev/null +++ b/crates/engine/src/cmd/fileio/pngtext.rs @@ -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); + } +} diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index de885ca5..f6f76276 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -50,7 +50,7 @@ fn per_metre(ppm: u32) -> f64 { } /// PNG chunks after the signature: (type, data). -fn png_chunks(b: &[u8]) -> impl Iterator { +pub(super) fn png_chunks(b: &[u8]) -> impl Iterator { let mut at = 8; std::iter::from_fn(move || { let len = be32(b, at)? as usize; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 80737f85..ad500329 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -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 { 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 { diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index aa81b98a..0aea86da 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -54,7 +54,12 @@ pub const OPTIONS: &[FormatOption] = &[ default: "false", description: "embed the native document in so VectorCraft reopens the SVG with nothing lost", }, - FormatOption { name: "metadata", ty: "boolean", default: "false", description: "write with the title and format (Dublin Core)" }, + FormatOption { + name: "metadata", + ty: "boolean", + default: "false", + description: "write with the title, format and File Info (Dublin Core)", + }, FormatOption { name: "fewerTspans", ty: "boolean", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 12e110b3..5696374f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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; diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index 1428670a..71ea7c34 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -558,6 +558,7 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { 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. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3e38a9d9..b145e2d1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -939,6 +939,8 @@ mod tests_eyedropper; #[cfg(test)] mod tests_file; #[cfg(test)] +mod tests_fileinfo; +#[cfg(test)] mod tests_flatpresets; #[cfg(test)] mod tests_flatpreview; diff --git a/crates/engine/src/tests_fileinfo.rs b/crates/engine/src/tests_fileinfo.rs new file mode 100644 index 00000000..2d4b6641 --- /dev/null +++ b/crates/engine/src/tests_fileinfo.rs @@ -0,0 +1,163 @@ +//! File → File Info (`file.info`): editing as one undo step, the native round trip, and the PDF, +//! PNG and SVG metadata it writes. + +use serde_json::{Value, json}; +use vectorcraft_doc::{CopyrightStatus, DocMetadata}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80, "title": "Poster"})).unwrap(); + s +} + +fn info() -> Value { + json!({ + "author": "Ada Lovelace", + "authorTitle": "Designer", + "description": "A poster\nfor the fair", + "keywords": "fair, Poster; print, poster", + "rating": 4, + "copyrightStatus": "copyrighted", + "copyrightNotice": "(c) 2026 Ada", + "copyrightUrl": "https://example.com/rights", + }) +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +#[test] +fn file_info_is_one_undo_step_and_reports_everything() { + let mut s = session(); + let r = s.execute("file.info", &info()).unwrap(); + assert_eq!(r["keywords"], json!(["fair", "Poster", "print"]), "keywords split, trimmed, each once ignoring case"); + assert_eq!((r["rating"].as_u64(), r["copyrightStatus"].as_str()), (Some(4), Some("copyrighted"))); + assert_eq!(r["description"], "A poster\nfor the fair"); + assert!(r["created"].as_str().is_some_and(|c| c.ends_with('Z')), "a new document has its creation date: {}", r["created"]); + assert!(r["modified"].is_null(), "never saved"); + assert_eq!(r["title"], "Poster"); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), 1); + assert_eq!(st.doc.metadata.author, "Ada Lovelace"); + // The report fed back changes nothing (read-only values are ignored). + let again = s.execute("file.info", &r).unwrap(); + assert_eq!(again, r); + assert_eq!(s.doc().unwrap().history.undo.len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + let m = &s.doc().unwrap().doc.metadata; + assert_eq!((m.author.as_str(), m.keywords.len()), ("", 0)); + // Clearing a field with null. + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("file.info", &json!({"author": null, "keywords": []})).unwrap(); + let m = &s.doc().unwrap().doc.metadata; + assert_eq!((m.author.as_str(), m.keywords.len(), m.rating), ("", 0, 4)); +} + +#[test] +fn junk_is_rejected() { + let mut s = session(); + for bad in [ + json!({"rating": 9}), + json!({"rating": -1}), + json!({"rating": "five"}), + json!({"keywords": 5}), + json!({"keywords": ["ok", 3]}), + json!({"copyrightStatus": "maybe"}), + json!({"copyrightUrl": "not a url"}), + json!({"author": "two\nlines"}), + json!({"description": "bell\u{7}"}), + json!({"title": " "}), + json!({"author": 3}), + json!({"favouriteColour": "blue"}), + json!([1, 2]), + json!({"keywords": "x".repeat(300)}), + ] { + assert!(s.execute("file.info", &bad).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), 0); + assert_eq!(s.doc().unwrap().doc.metadata, DocMetadata { created: s.doc().unwrap().doc.metadata.created, ..Default::default() }); +} + +#[test] +fn file_info_round_trips_and_old_files_load() { + let mut s = session(); + s.execute("file.info", &info()).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap(); + assert_eq!(back.metadata, d.metadata); + assert_eq!(back.metadata.copyright_status, CopyrightStatus::Copyrighted); + // A document saved before File Info existed has none. + let mut old = vectorcraft_doc::Document::new(10.0, 10.0); + old.metadata = DocMetadata::default(); + let bytes = vectorcraft_format::save_file(&old); + assert!(!String::from_utf8_lossy(&bytes).contains("\"metadata\""), "default File Info isn't written"); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().metadata, DocMetadata::default()); +} + +#[test] +fn saving_to_a_file_stamps_the_modified_date() { + let mut s = session(); + let created = s.doc().unwrap().doc.metadata.created; + let path = std::env::temp_dir().join(format!("vc-fileinfo-{}.vectorcraft", std::process::id())); + s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.metadata.modified.is_some() && st.doc.metadata.created == created); + assert!(!st.is_dirty(), "the stamped document is the saved one"); + let back = vectorcraft_format::load(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(back.metadata.modified, st.doc.metadata.modified); + let _ = std::fs::remove_file(&path); + assert!(s.execute("file.info", &json!({})).unwrap()["modified"].is_string()); +} + +#[test] +fn pdf_info_carries_author_subject_and_keywords() { + let mut s = session(); + s.execute("file.info", &info()).unwrap(); + let pdf = crate::export_pdf(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); + let text = String::from_utf8_lossy(&pdf); + // (The description has a line break, so it is written as UTF-16.) + for want in ["/Author(Ada Lovelace)", "/Keywords(fair, Poster, print)", "/Title(Poster)", "/Subject") && text.contains("Ada Lovelace")); +} + +#[test] +fn png_exports_carry_text_chunks() { + let mut s = session(); + s.execute("file.info", &info()).unwrap(); + let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap()); + let text = crate::cmd::fileio::pngtext::text(&png); + let get = |k: &str| text.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str()); + assert_eq!(get("Title"), Some("Poster")); + assert_eq!(get("Author"), Some("Ada Lovelace")); + assert_eq!(get("Keywords"), Some("fair, Poster, print")); + assert_eq!(get("Copyright"), Some("(c) 2026 Ada")); + assert!(get("Creation Time").is_some_and(|t| t.ends_with('Z'))); + assert!(image::load_from_memory(&png).is_ok(), "still a valid PNG"); +} + +#[test] +fn svg_metadata_carries_file_info_as_dublin_core() { + let mut s = session(); + s.execute("file.info", &info()).unwrap(); + let svg = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg", "metadata": true})).unwrap())).unwrap(); + for want in [ + "Poster", + "Ada Lovelace", + "fair", + "print", + "(c) 2026 Ada", + "https://example.com/rights", + "A poster\nfor the fair", + ] { + assert!(svg.contains(want), "SVG has {want}"); + } + // Without the option: no , but the title and description stay. + let plain = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg"})).unwrap())).unwrap(); + assert!(!plain.contains("") && plain.contains("")); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index ca6c069b..17ab2040 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -69,7 +69,19 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Option { std::str::from_utf8(digits).ok()?.parse().ok() } -#[cfg(not(target_arch = "wasm32"))] -fn now_unix() -> Option { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64) -} -#[cfg(target_arch = "wasm32")] -fn now_unix() -> Option { - None -} - -/// Unix seconds (UTC) → krilla date (civil-from-days, proleptic Gregorian). +/// Unix seconds (UTC) → krilla date. fn date_time(t: i64) -> krilla::metadata::DateTime { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); - krilla::metadata::DateTime::new(year.clamp(0, 9999) as u16) + let [year, month, day, hour, minute, second] = vectorcraft_doc::metadata::civil(t); + krilla::metadata::DateTime::new(year as u16) .month(month as u8) .day(day as u8) - .hour((secs / 3600) as u8) - .minute((secs / 60 % 60) as u8) - .second((secs % 60) as u8) + .hour(hour as u8) + .minute(minute as u8) + .second(second as u8) .utc_offset_hour(0) .utc_offset_minute(0) } diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs index eaa541c4..7b703e32 100644 --- a/crates/render/src/encode/png.rs +++ b/crates/render/src/encode/png.rs @@ -84,6 +84,44 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result, entries: &[(&str, Cow)]) -> Vec { + // The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first. + const HEADER: usize = 8 + 12 + 13; + if entries.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER { + return png; + } + let mut out = Vec::with_capacity(png.len() + entries.iter().map(|(k, v)| k.len() + v.len() + 20).sum::()); + out.extend_from_slice(&png[..HEADER]); + for (keyword, text) in entries { + if keyword.is_empty() || keyword.len() > 79 || !keyword.bytes().all(|b| (32..=126).contains(&b)) { + continue; + } + let mut data = keyword.as_bytes().to_vec(); + data.push(0); + let latin1: Option> = text.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect(); + let ty = match latin1 { + Some(bytes) => { + data.extend(bytes); + b"tEXt" + } + None => { + // Uncompressed, no language tag, no translated keyword. + data.extend_from_slice(&[0, 0, 0, 0]); + data.extend_from_slice(text.as_bytes()); + b"iTXt" + } + }; + if u32::try_from(data.len()).is_ok() { + chunk(&mut out, ty, &data); + } + } + out.extend_from_slice(&png[HEADER..]); + out +} + /// Append one chunk: length, type, data, CRC of type and data. fn chunk(out: &mut Vec, ty: &[u8; 4], data: &[u8]) { out.extend_from_slice(&(data.len() as u32).to_be_bytes()); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 0aaf41ff..82be15b8 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -112,6 +112,11 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!("{}{nl}", xml_escape(&doc.title))); } + // File Info's description. + if !doc.metadata.description.trim().is_empty() { + out.push_str(&w.indent(1)); + out.push_str(&format!("{}{nl}", xml_escape(&doc.metadata.description))); + } // The editing data carries a hash of the markup around its ``. let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "", nl]))); w.metadata(&mut out, native); @@ -301,12 +306,17 @@ fn pin_to_page(n: &mut Node, m: Affine) { } } -/// The Dublin Core terms File Info writes to ``. +/// The Dublin Core terms File Info writes to ``: the format, title, author (creator), +/// description, each keyword (subject), copyright notice and URL (rights) and created date, the +/// empty ones left out. fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { + let info = &doc.metadata; let mut terms = vec![("format", "image/svg+xml".to_string())]; - if !doc.title.is_empty() { - terms.push(("title", doc.title.clone())); - } + 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 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()))); terms } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a79e209a..e8b28ee6 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -139,7 +139,7 @@ pub struct ExportOptions { /// Embed the native document (passed to [`export_full`]) in `` so VectorCraft /// reopens the SVG with nothing lost (see [`editing`]). pub preserve_editing: bool, - /// Write `` with the document's Dublin Core title and format. + /// Write `` with the document's title, format and File Info (Dublin Core). pub metadata: bool, /// One positioned `` per line of type instead of one per style run, tab stop and /// justified word (smaller; viewers then space the line with their own font metrics). diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs index 0319715d..6cead6d9 100644 --- a/crates/ui-egui/src/dialogs/document_setup.rs +++ b/crates/ui-egui/src/dialogs/document_setup.rs @@ -18,7 +18,7 @@ pub(super) const KIND: &str = "documentSetup"; const TABS: [&str; 3] = ["General", "Transparency", "Type"]; /// Dialog-only fields `document.setup` doesn't take. const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"]; -const LABEL: f32 = 130.0; +pub(super) const LABEL: f32 = 130.0; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Document Setup".into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM }; @@ -60,7 +60,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } /// A dropdown row bound to the string `d.fields[key]`; `options` are (value, label). -fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) { +pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) { let cur = d.str(key); let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l); let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect(); @@ -72,7 +72,7 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[ } /// A checkbox bound to `d.fields[key]`. -fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { +pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { let on = d.bool(key); if widgets::check(ui, label, on, true) { d.fields.insert(key.into(), json!(!on)); diff --git a/crates/ui-egui/src/dialogs/file_info.rs b/crates/ui-egui/src/dialogs/file_info.rs new file mode 100644 index 00000000..18333521 --- /dev/null +++ b/crates/ui-egui/src/dialogs/file_info.rs @@ -0,0 +1,234 @@ +//! File → File Info: the document title, author, description, rating, keywords (chips) and +//! copyright, with the created and modified dates. +//! +//! Fields: what `file.info` reports (and takes back) plus `__keyword`, the keyword being typed +//! (Enter or Add turns it into chips; OK keeps it too). OK runs `file.info` with them: one undo +//! step. On an error the dialog stays open. + +use serde_json::{Value, json}; +use vectorcraft_doc::CopyrightStatus; +use vectorcraft_doc::metadata::MAX_RATING; + +use super::document_setup::{LABEL, choice}; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, icons, widgets}; + +pub(super) const KIND: &str = "fileInfo"; +/// The keyword being typed. +const PENDING: &str = "__keyword"; +/// Width of the text fields. +const FIELD: f32 = 320.0; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "File Info".into(), body, confirm, min_width: 500.0, max_width: Some(500.0), ..DialogSpec::FORM }; + +/// Open File Info on the active document's. +pub fn open(app: &mut VectorcraftApp) -> Result { + let mut fields = app.session.execute("file.info", &json!({})).map_err(|e| e.to_string())?; + fields[PENDING] = json!(""); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut d = d.clone(); + // A keyword typed but not added yet is kept. + add_keywords(&mut d); + let r = app.run("file.info", form::params(&d)); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +/// The keywords in `d`. +fn keywords(d: &Dialog) -> Vec { + d.fields.get("keywords").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() +} + +/// Move the typed keywords (comma- or semicolon-separated) into the list, each once. +fn add_keywords(d: &mut Dialog) { + let mut list = keywords(d); + for k in d.str(PENDING).split([',', ';']).map(str::trim).filter(|k| !k.is_empty()) { + if !list.iter().any(|o| o.to_lowercase() == k.to_lowercase()) { + list.push(k.to_string()); + } + } + d.fields.insert("keywords".into(), json!(list)); + d.fields.insert(PENDING.into(), json!("")); +} + +fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + for (key, label) in [("title", "Document Title:"), ("author", "Author:"), ("authorTitle", "Author Title:")] { + widgets::label_row(ui, label, LABEL, |ui| { + form::text(ui, d, key, FIELD); + }); + ui.add_space(4.0); + } + top_row(ui, "Description:", |ui| { + form::text_area(ui, d, "description", FIELD, 3); + }); + ui.add_space(4.0); + widgets::label_row(ui, "Rating:", LABEL, |ui| rating(ui, d)); + ui.add_space(4.0); + top_row(ui, "Keywords:", |ui| { + ui.vertical(|ui| { + ui.set_max_width(FIELD + 12.0); + chips(ui, d); + ui.horizontal(|ui| { + let r = form::text_edit(ui, d, PENDING, FIELD - 70.0); + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if enter { + // Enter adds the keyword (it doesn't press OK) and keeps typing in the field. + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + r.request_focus(); + } + let typed = !d.str(PENDING).trim().is_empty(); + if (widgets::flat_button(ui, "Add", 56.0).clicked() || enter) && typed { + add_keywords(d); + } + }); + form::caption(ui, "Separate several keywords with commas."); + }); + }); + ui.add_space(10.0); + widgets::subheader(ui, "Copyright"); + let statuses: Vec<(&str, &str)> = CopyrightStatus::ALL.iter().map(|c| (c.id(), c.label())).collect(); + choice(ui, d, "copyrightStatus", "Copyright Status:", &statuses); + ui.add_space(4.0); + top_row(ui, "Copyright Notice:", |ui| { + form::text_area(ui, d, "copyrightNotice", FIELD, 2); + }); + ui.add_space(4.0); + widgets::label_row(ui, "Copyright Info URL:", LABEL, |ui| { + form::text(ui, d, "copyrightUrl", FIELD); + }); + ui.add_space(10.0); + widgets::subheader(ui, "Dates"); + for (key, label) in [("created", "Created:"), ("modified", "Modified:")] { + // ISO 8601 UTC from the engine, shown as "2026-10-05 14:03:00 UTC". + let shown = d + .fields + .get(key) + .and_then(Value::as_str) + .map_or_else(|| "—".to_string(), |s| format!("{} UTC", s.replace('T', " ").trim_end_matches('Z'))); + widgets::label_row(ui, label, LABEL, |ui| { + ui.label(egui::RichText::new(shown).color(t.text_dim)); + }); + } + false +} + +/// A form row whose label sits at the top of a taller field. +fn top_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(LABEL, 24.0), egui::Sense::hover()); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); + add(ui); + }); +} + +/// Five stars: a click sets the rating, a click on the current one clears it. +fn rating(ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let current = d.f64("rating", 0.0) as u8; + ui.spacing_mut().item_spacing.x = 2.0; + for i in 1..=MAX_RATING { + let (rect, resp) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::click()); + let on = i <= current; + let tint = if on { + t.accent + } else if resp.hovered() { + t.text + } else { + t.text_dim + }; + icons::paint(ui, "star", rect.shrink(2.0), tint); + let resp = resp.on_hover_text(if i == 1 { "1 star".to_string() } else { format!("{i} stars") }); + if resp.clicked() { + d.fields.insert("rating".into(), json!(if i == current { 0 } else { i })); + } + } +} + +/// The keywords as chips; a chip's × removes it. +fn chips(ui: &mut egui::Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let list = keywords(d); + if list.is_empty() { + return; + } + let mut remove = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); + for (i, k) in list.iter().enumerate() { + let galley = ui.painter().layout_no_wrap(k.clone(), egui::FontId::proportional(12.0), t.text_strong); + let size = egui::vec2(galley.size().x + 30.0, 22.0); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + ui.painter().rect_filled(rect, egui::CornerRadius::same(11), t.hover); + ui.painter().rect_stroke(rect, egui::CornerRadius::same(11), egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + ui.painter().galley(egui::pos2(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, t.text_strong); + let x = egui::Rect::from_center_size(egui::pos2(rect.right() - 11.0, rect.center().y), egui::vec2(14.0, 14.0)); + let resp = ui.interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click()).on_hover_text(format!("Remove “{k}”")); + icons::paint(ui, "x", x.shrink(2.0), if resp.hovered() { t.text_strong } else { t.text_dim }); + if resp.clicked() { + remove = Some(i); + } + } + }); + if let Some(i) = remove { + let mut list = list; + list.remove(i); + d.fields.insert("keywords".into(), json!(list)); + } + ui.add_space(4.0); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use crate::VectorcraftApp; + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn file_info_draws_and_ok_is_one_undo_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + // The menu item and its shortcut open the dialog; agents use ui.fileInfoDialog. + crate::menus::invoke(&mut app, "file.info", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::KIND)); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.fields["created"].is_string(), "a new document has its creation date"); + for (k, v) in [("author", json!("Ada")), ("keywords", json!(["poster"])), ("rating", json!(4)), ("copyrightStatus", json!("copyrighted"))] { + d.fields.insert(k.into(), v); + } + d.fields.insert(super::PENDING.into(), json!("print, Poster; blue")); + frame(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.active().unwrap(); + let m = &st.doc.metadata; + assert_eq!((m.author.as_str(), m.rating, m.copyright_status), ("Ada", 4, vectorcraft_doc::CopyrightStatus::Copyrighted)); + assert_eq!(m.keywords, ["poster", "print", "blue"], "typed keywords join the chips, each once"); + assert_eq!(st.history.undo.len(), 1); + // A bad value keeps the dialog open. + app.run("ui.fileInfoDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("rating".into(), json!(9)); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + } +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 0fb85a48..88f49c96 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -73,6 +73,26 @@ pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32 r.inner } +/// A text box `rows` lines tall and `width` wide bound to `d.fields[key]`. Enter adds a line (it +/// doesn't press OK while the box has focus). +pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, rows: usize) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let mut s = d.str(key); + let r = egui::Frame::NONE + .fill(t.input) + .stroke(egui::Stroke::new(1.0, t.input_border)) + .corner_radius(egui::CornerRadius::same(3)) + .inner_margin(egui::Margin::symmetric(6, 3)) + .show(ui, |ui| ui.add(egui::TextEdit::multiline(&mut s).frame(egui::Frame::NONE).desired_width(width).desired_rows(rows))); + if r.inner.changed() { + d.fields.insert(key.into(), Value::String(s)); + } + if r.inner.has_focus() { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + r.inner +} + /// A checkbox bound to `d.fields[key]`. pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { let mut b = d.bool(key); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 8493313b..2a012427 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -20,6 +20,7 @@ pub mod expand; mod export_as; mod export_for_screens; pub mod eyedropper; +pub mod file_info; pub mod flatten; pub mod flattener_presets; mod form; @@ -190,6 +191,7 @@ registry! { ExportAs: ["exportAs"] => export_as::SPEC, ImportPdf: [import_pdf::KIND] => import_pdf::SPEC, SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC, + FileInfo: [file_info::KIND] => file_info::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index adb61136..4df83cde 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -109,6 +109,7 @@ pub fn save(app: &mut VectorcraftApp, path: Option, save_as: bool, param let existing = if save_as { None } else { st.path.clone() }; let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?; let f = fileio::save_format(format_param(params), Some(&path))?; + fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?); let st = app.session.active().ok_or("no document")?; let (enc, opts) = fileio::save_encoding(st, f, params).map_err(|e| e.to_string())?; let doc = st.doc.clone(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f07429ab..f4ee5c68 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -382,6 +382,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}", ), + ( + "ui.fileInfoDialog", + "File Info Dialog", + "", + "{} open File Info (dialog `fileInfo`: the file.info fields, plus __keyword, keywords typed but not added yet); OK runs file.info. The File Info… menu item opens it too", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -753,6 +759,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::export(app, s("format").as_deref(), s("path"), p), + "ui.fileInfoDialog" => crate::dialogs::file_info::open(app), _ => return None, }; Some(r) @@ -930,7 +937,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { } "effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), - "file.export.pdf" | "ui.savePdfDialog" => app.session.active().is_some(), + "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" => app.session.active().is_some(), "ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(), "ui.graphicStyleOptions" => app.session.active().is_some(), "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), @@ -1740,6 +1747,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // File Info: its dialog. + if id == "file.info" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::file_info::open(app) { + app.status(e); + } + return; + } // Text Wrap Options: a dialog with the selected wrap object's current values. if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { match app.session.execute(id, &json!({})) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e5b4cace..b251d8b6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -239,3 +239,10 @@ paste at the centre of the view. The Paste menu items are enabled while the syst nothing copied in the app (looked at up to four times a second; `ui.menu.list` shows it). The Layers panel menu (≡ on the dock's tab strip while Layers shows) lists the layer commands and Paste Remembers Layers (`layer.pasteRemembersLayers`, checked when on). + +File Info: File → File Info… (`file.info` from the menu or Cmd+Alt+Shift+I; `ui.fileInfoDialog` for agents) opens the +`fileInfo` dialog. Its fields are what `file.info` reports (`title`, `author`, `authorTitle`, `description`, +`keywords` (a list: the chips), `rating`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`; `created` and +`modified` are shown read-only) plus `__keyword`, keywords typed but not added yet (comma-separated; OK adds them). +`ui.dialog.confirm` runs `file.info` with them as one undo step; a bad value answers with an error and the dialog +stays open. diff --git a/docs/mcp.md b/docs/mcp.md index ffd98e2e..82eb3ce8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -892,3 +892,18 @@ pastes objects back into the layers they came from, by name, making missing ones {"name":"run_command","arguments":{"command":"edit.paste","params":{"center":[300,200],"swatchConflict":{"Brand":"add"}}}} {"name":"run_command","arguments":{"command":"layer.pasteRemembersLayers","params":{"on":true}}} ``` + +## File Info + +`file.info` with no params reports the document's File Info: `title`, `author`, `authorTitle`, `description`, +`keywords`, `rating` (0–5), `copyrightStatus` (`unknown`, `copyrighted`, `publicDomain`), `copyrightNotice`, +`copyrightUrl`, and the read-only `created` (set by `file.new`) and `modified` (set by every save to a file) dates as +ISO 8601 UTC. Pass any of the editable keys to change them in one undo step (`null` clears one; `keywords` takes a +list or a comma-separated string and keeps each word once); bad values are refused. PDF export writes the title, +author, description and keywords to the document info and XMP, PNG export writes them (with the copyright and +creation date) as text chunks, and SVG export writes the description as `` and, with the `metadata` option, +all of it as Dublin Core. + +```json +{"name":"run_command","arguments":{"command":"file.info","params":{"author":"Ada","keywords":"poster, fair","rating":4,"copyrightStatus":"copyrighted","copyrightNotice":"© 2026 Ada"}}} +```