mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
a908ba0190
commit
6a446b4fa3
26 files changed
+913
-60
No files matched your search
@@ -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))));
|
||||
|
||||
@@ -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> {
|
||||
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<String>,
|
||||
/// 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<i64>,
|
||||
/// When the document was last saved, as Unix seconds (UTC).
|
||||
pub modified: Option<i64>,
|
||||
}
|
||||
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u8> {
|
||||
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<FEFF0041"] {
|
||||
assert!(text.contains(want), "PDF Info has {want}");
|
||||
}
|
||||
// XMP too.
|
||||
assert!(text.contains("<dc:creator>") && 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 [
|
||||
"<dc:title>Poster</dc:title>",
|
||||
"<dc:creator>Ada Lovelace</dc:creator>",
|
||||
"<dc:subject>fair</dc:subject>",
|
||||
"<dc:subject>print</dc:subject>",
|
||||
"<dc:rights>(c) 2026 Ada</dc:rights>",
|
||||
"<dc:rights>https://example.com/rights</dc:rights>",
|
||||
"<desc>A poster\nfor the fair</desc>",
|
||||
] {
|
||||
assert!(svg.contains(want), "SVG has {want}");
|
||||
}
|
||||
// Without the option: no <metadata>, but the title and description stay.
|
||||
let plain = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg"})).unwrap())).unwrap();
|
||||
assert!(!plain.contains("<metadata>") && plain.contains("<desc>"));
|
||||
}
|
||||
+19
-27
@@ -69,7 +69,19 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
if !title.is_empty() {
|
||||
meta = meta.title(title);
|
||||
}
|
||||
if let Some(t) = opts.created.or_else(now_unix) {
|
||||
// File Info.
|
||||
let info = &doc.metadata;
|
||||
let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
if let Some(a) = given(&info.author) {
|
||||
meta = meta.authors(vec![a]);
|
||||
}
|
||||
if let Some(d) = given(&info.description) {
|
||||
meta = meta.description(d);
|
||||
}
|
||||
if !info.keywords.is_empty() {
|
||||
meta = meta.keywords(info.keywords.clone());
|
||||
}
|
||||
if let Some(t) = opts.created.or_else(vectorcraft_doc::metadata::now_unix) {
|
||||
meta = meta.creation_date(date_time(t));
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
@@ -175,35 +187,15 @@ fn number(digits: &[u8]) -> Option<u32> {
|
||||
std::str::from_utf8(digits).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn now_unix() -> Option<i64> {
|
||||
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<i64> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,44 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Ve
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `png` (a PNG file) with a text chunk per `(keyword, text)` right after its header: `tEXt`, or
|
||||
/// uncompressed `iTXt` (UTF-8) for text beyond Latin-1. Keywords are 1–79 printable ASCII
|
||||
/// characters (others are skipped). Unchanged when it doesn't start with a PNG header.
|
||||
pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
|
||||
// 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::<usize>());
|
||||
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<Vec<u8>> = 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<u8>, ty: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
|
||||
@@ -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!("<title>{}</title>{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!("<desc>{}</desc>{nl}", xml_escape(&doc.metadata.description)));
|
||||
}
|
||||
// The editing data carries a hash of the markup around its `<metadata>`.
|
||||
let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "</svg>", 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 `<metadata>`.
|
||||
/// The Dublin Core terms File Info writes to `<metadata>`: 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
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ pub struct ExportOptions {
|
||||
/// Embed the native document (passed to [`export_full`]) in `<metadata>` so VectorCraft
|
||||
/// reopens the SVG with nothing lost (see [`editing`]).
|
||||
pub preserve_editing: bool,
|
||||
/// Write `<metadata>` with the document's Dublin Core title and format.
|
||||
/// Write `<metadata>` with the document's title, format and File Info (Dublin Core).
|
||||
pub metadata: bool,
|
||||
/// One positioned `<tspan>` 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).
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -109,6 +109,7 @@ pub fn save(app: &mut VectorcraftApp, path: Option<String>, 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();
|
||||
|
||||
@@ -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<R
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.exportAs" => 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!({})) {
|
||||
|
||||
@@ -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.
|
||||
+15
@@ -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 `<desc>` 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"}}}
|
||||
```
|
||||
Reference in new issue
Block a user