From 6a446b4fa3f9da2445214479215b3761c8c20fdb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 09:34:49 +0700 Subject: [PATCH 1/2] 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 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 --- crates/doc/src/lib.rs | 6 + crates/doc/src/metadata.rs | 151 ++++++++++++ crates/engine/src/cmd/docmenu.rs | 22 +- crates/engine/src/cmd/fileinfo.rs | 142 +++++++++++ crates/engine/src/cmd/fileio/encode.rs | 9 +- crates/engine/src/cmd/fileio/mod.rs | 3 +- crates/engine/src/cmd/fileio/pngtext.rs | 47 ++++ crates/engine/src/cmd/fileio/ppi.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 12 + crates/engine/src/cmd/fileio/svg.rs | 7 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/newdoc.rs | 1 + crates/engine/src/lib.rs | 2 + crates/engine/src/tests_fileinfo.rs | 163 +++++++++++++ crates/pdf/src/export.rs | 46 ++-- crates/render/src/encode/png.rs | 38 +++ crates/svg/src/export.rs | 18 +- crates/svg/src/lib.rs | 2 +- crates/ui-egui/src/dialogs/document_setup.rs | 6 +- crates/ui-egui/src/dialogs/file_info.rs | 234 +++++++++++++++++++ crates/ui-egui/src/dialogs/form.rs | 20 ++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/io.rs | 1 + crates/ui-egui/src/menus.rs | 16 +- docs/control-protocol.md | 7 + docs/mcp.md | 15 ++ 26 files changed, 913 insertions(+), 60 deletions(-) create mode 100644 crates/doc/src/metadata.rs create mode 100644 crates/engine/src/cmd/fileinfo.rs create mode 100644 crates/engine/src/cmd/fileio/pngtext.rs create mode 100644 crates/engine/src/tests_fileinfo.rs create mode 100644 crates/ui-egui/src/dialogs/file_info.rs 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"}}} +``` From 94f0f1b0d2d007031efda4d2fa81daf3a006af9f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 09:42:15 +0700 Subject: [PATCH 2/2] M4.56: Document Raster Effects Settings with colour model, background, anti-alias, clipping mask and room around MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit document.rasterEffectsSettings now also takes and reports colorModel, background, antiAlias, clippingMask, addAround and preserveSpotColors in one undo step (Document.raster_effects, RasterEffectsSettings, next to the existing raster_effects_ppi so old files load unchanged). The raster effect images of PDF export and Expand Appearance get the extra room and are finished by the settings (hard edges, grayscale or bitmap, opaque on white, white only under the art with a clipping mask). Rasterize takes them as defaults (params override), and its clipping mask puts the image in a clip group with the art's outline. The settings have their own dialog (rasterEffectsSettings, ui.rasterEffectsSettingsDialog) and Rasterize… opens its options prefilled from them. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 7 + crates/doc/src/rastersettings.rs | 135 ++++++++++++++++ crates/engine/src/cmd/flatten.rs | 7 +- crates/engine/src/cmd/menucmds.rs | 67 ++++++-- crates/engine/src/cmd/rasterfx.rs | 143 ++++++++++++++--- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_rastersettings.rs | 160 +++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/raster_effects.rs | 152 ++++++++++++++++++ crates/ui-egui/src/menus.rs | 35 ++-- docs/control-protocol.md | 7 + docs/mcp.md | 14 ++ 12 files changed, 673 insertions(+), 58 deletions(-) create mode 100644 crates/doc/src/rastersettings.rs create mode 100644 crates/engine/src/tests_rastersettings.rs create mode 100644 crates/ui-egui/src/dialogs/raster_effects.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4687529..f0e468d3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -15,6 +15,7 @@ pub mod node; pub mod overprint; pub mod pattern; mod pixels; +pub mod rastersettings; mod reach; pub mod selection; pub mod setup; @@ -52,6 +53,7 @@ pub use node::Scaling; pub use node::{ImageMap, ObjectAttributes}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; +pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; pub use selection::{AnchorRef, Selection}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use style_libs::StyleLibrary; @@ -481,6 +483,10 @@ pub struct Document { /// File → File Info (the title is [`Document::title`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub metadata: DocMetadata, + /// Effect → Document Raster Effects Settings besides the resolution + /// ([`Document::raster_effects_ppi`]). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub raster_effects: RasterEffectsSettings, } fn ppi72() -> f64 { @@ -539,6 +545,7 @@ impl Document { setup: DocSetup::default(), paste_remembers_layers: false, metadata: DocMetadata::default(), + raster_effects: RasterEffectsSettings::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/rastersettings.rs b/crates/doc/src/rastersettings.rs new file mode 100644 index 00000000..8bc62e9a --- /dev/null +++ b/crates/doc/src/rastersettings.rs @@ -0,0 +1,135 @@ +//! Effect → Document Raster Effects Settings, besides the resolution +//! ([`crate::Document::raster_effects_ppi`]): how raster effects (shadows, glows, blurs, feathers) +//! become pixels when they are flattened for output or expanded, and the defaults of Rasterize. +//! Every field is defaulted, so documents saved before these settings existed load unchanged. + +use serde::{Deserialize, Serialize}; + +use crate::setup::Background; + +/// The colour model raster effect images are made in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RasterColorModel { + /// The document's colour mode (RGB or CMYK). + #[default] + Document, + Grayscale, + /// Black and white pixels only. + Bitmap, +} + +impl RasterColorModel { + pub const ALL: [RasterColorModel; 3] = [RasterColorModel::Document, RasterColorModel::Grayscale, RasterColorModel::Bitmap]; + pub fn id(self) -> &'static str { + match self { + RasterColorModel::Document => "document", + RasterColorModel::Grayscale => "grayscale", + RasterColorModel::Bitmap => "bitmap", + } + } +} + +/// Largest Add … Around Object, in points. +pub const MAX_ADD_AROUND: f64 = 1000.0; + +/// Document Raster Effects Settings (resolution aside). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct RasterEffectsSettings { + pub color_model: RasterColorModel, + /// White: images are opaque, on white. Transparent: they keep their transparency. + pub background: Background, + /// Off: the art's edges are hard (raster effects stay smooth). + pub anti_alias: bool, + /// With a white background, the white stays only under the art (outside it the image is + /// transparent); Rasterize clips its image to the art's outline. + pub clipping_mask: bool, + /// Extra room around the art, in points (0–[`MAX_ADD_AROUND`]). + pub add_around: f64, + /// Keep spot colours in raster effects (stored; effect images are made in process colour). + pub preserve_spot_colors: bool, +} + +impl Default for RasterEffectsSettings { + fn default() -> Self { + Self { + color_model: RasterColorModel::Document, + background: Background::Transparent, + anti_alias: true, + clipping_mask: false, + add_around: 0.0, + preserve_spot_colors: true, + } + } +} + +impl RasterEffectsSettings { + /// Turn premultiplied RGBA8 pixels into what these settings make of them: the colour model, + /// then the background. (Anti-aliasing is the renderer's.) + pub fn finish_pixels(&self, pixels: &mut [u8]) { + let white = self.background == Background::White; + if self.color_model == RasterColorModel::Document && !white { + return; + } + for px in pixels.as_chunks_mut::<4>().0 { + let a = px[3]; + if self.color_model != RasterColorModel::Document && a != 0 { + // Rec. 709 luma of the premultiplied colour (still premultiplied). + let y = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32; + let y = match self.color_model { + RasterColorModel::Bitmap => { + if y >= a as f32 / 2.0 { + a + } else { + 0 + } + } + _ => y.round().min(a as f32) as u8, + }; + px[..3].fill(y); + } + // Over white; a clipping mask leaves what the art doesn't cover transparent. + if white && !(self.clipping_mask && a == 0) { + let under = 255 - a; + for c in &mut px[..3] { + *c = c.saturating_add(under); + } + px[3] = 255; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn finish(s: RasterEffectsSettings, px: [u8; 4]) -> [u8; 4] { + let mut p = px; + s.finish_pixels(&mut p); + p + } + + #[test] + fn pixels_follow_the_settings() { + let d = RasterEffectsSettings::default(); + assert_eq!(finish(d.clone(), [100, 0, 0, 100]), [100, 0, 0, 100], "the defaults change nothing"); + let white = RasterEffectsSettings { background: Background::White, ..d.clone() }; + assert_eq!(finish(white.clone(), [0, 0, 0, 0]), [255, 255, 255, 255]); + assert_eq!(finish(white.clone(), [100, 0, 0, 100]), [255, 155, 155, 255]); + let masked = RasterEffectsSettings { clipping_mask: true, ..white }; + assert_eq!(finish(masked, [0, 0, 0, 0]), [0, 0, 0, 0]); + let gray = RasterEffectsSettings { color_model: RasterColorModel::Grayscale, ..d.clone() }; + assert_eq!(finish(gray, [255, 0, 0, 255]), [54, 54, 54, 255]); + let bitmap = RasterEffectsSettings { color_model: RasterColorModel::Bitmap, ..d }; + assert_eq!(finish(bitmap.clone(), [255, 0, 0, 255]), [0, 0, 0, 255]); + assert_eq!(finish(bitmap, [0, 255, 0, 255]), [255, 255, 255, 255]); + } + + #[test] + fn partial_settings_fill_in_the_rest() { + let s: RasterEffectsSettings = serde_json::from_str(r#"{"background":"white"}"#).unwrap(); + assert_eq!(s, RasterEffectsSettings { background: Background::White, ..Default::default() }); + } +} diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 001d2011..fa8ab22d 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1041,12 +1041,7 @@ fn raster_node(d: &mut Document, r: Raster) -> Node { d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(r.png) }); let image = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None })); let Some(path) = r.clip else { return image }; - let mut clip = shape_node(d, path, None); - clip.appearance = Appearance::basic(Paint::None, Paint::None, 0.0); - if let NodeKind::Path { clipping, .. } = &mut clip.kind { - *clipping = true; - } - Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }) + super::rasterfx::clip_group(d, path, FillRule::NonZero, image) } /// Replace the type under `root` by its outlines: → the id now standing for `root`. diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index a271e506..3d6a2d91 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -7,8 +7,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::marks::{MarkStyle, TrimMarks}; -use vectorcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2}; +use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::edit::{duplicate_in, selected_roots}; @@ -90,7 +91,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?: (document raster effects resolution), background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art (default)|type (text snapped to pixels), padding?: pt} replace the selection with an embedded PNG image → {id, width, height}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height}", has_selection, rasterize ), @@ -318,12 +319,20 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String { } } -fn render_png(doc: &vectorcraft_doc::Document, region: Rect, scale: f64, opts: &vectorcraft_render::RenderOptions) -> Result<(Vec, u32, u32)> { +/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`. +fn render_png( + doc: &vectorcraft_doc::Document, + region: Rect, + scale: f64, + opts: &vectorcraft_render::RenderOptions, + color: RasterColorModel, +) -> Result<(Vec, u32, u32)> { if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 { return Err(EngineError::Other("image would be too large; lower the resolution".into())); } let mut r = vectorcraft_render::Renderer::new(); - let img = r.render_region_with(doc, region, scale, opts); + let mut img = r.render_region_with(doc, region, scale, opts); + RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels); Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height)) } @@ -335,13 +344,33 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { if !(1.0..=2400.0).contains(&ppi) { return Err(bad(C, "ppi must be between 1 and 2400")); } - let pad = f64_or(p, "padding", 0.0).max(0.0); - let raster = RasterExportOptions { - ppi, - background: fileio::background(p.get("background").unwrap_or(&Value::Null)).map_err(|e| bad(C, e))?, - anti_alias: str_param(p, "antiAlias").map(fileio::anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(), - ..Default::default() - }; + // The document's raster effects settings, changed by the params. + let mut look = st.doc.raster_effects.clone(); + let mut background = (look.background == vectorcraft_doc::Background::White).then_some([255; 3]); + let mut anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None }; + for (k, v) in p.as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) { + match k.as_str() { + "background" => background = fileio::background(v).map_err(|e| bad(C, e))?, + "antiAlias" => { + anti_alias = match v { + Value::Bool(b) => { + if *b { + AntiAlias::Art + } else { + AntiAlias::None + } + } + v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(C, e))?, + } + } + "padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, C, k)?, + "colorModel" => look.color_model = super::rasterfx::color_model(v, st.doc.color_mode, C)?, + "clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(C, "clippingMask must be true or false"))?, + _ => {} + } + } + let raster = RasterExportOptions { ppi, background, anti_alias, ..Default::default() }; + let pad = look.add_around; let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad); let scale = ppi / 72.0; // Snap to whole pixels. @@ -354,8 +383,14 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { n }) .collect(); + // Create Clipping Mask: the image is clipped to the art's outline. + let clip = look + .clipping_mask + .then(|| vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect()))) + .flatten() + .map(|(bp, rule)| (PathData::from_bezpath(&bp), rule)); let tmp = isolated_doc(&st.doc, nodes); - let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png))?; + let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?; let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?; let id = s.edit("Rasterize", |d, sel| { let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; @@ -363,7 +398,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); let id = d.alloc_id(); let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None })); + let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None })); + if let Some((path, rule)) = clip { + node = super::rasterfx::clip_group(d, path, rule, node); + } + let id = node.id; d.insert(par, idx + 1, node)?; for r in &roots { d.remove(*r)?; @@ -505,7 +544,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { bare.visible = true; let tmp = isolated_doc(&st.doc, vec![bare]); let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale); - let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png))?; + let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?; s.edit("Crop Image", |d, _| { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 8233976b..1a670115 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -1,17 +1,21 @@ //! Effect → Document Raster Effects Settings, and raster effects for PDF export. //! //! PDF has no live shadows, glows or blurs, so PDF export receives a copy of the document in which -//! every object with a raster effect is accompanied by an image rendered at the document's raster -//! effects resolution (like Illustrator). Shadows and outer glows only add the effect: the image -//! (with the object's own area knocked out) goes under the untouched vector object. Effects that -//! change the object itself (inner glow, feather, Gaussian blur) replace it with the image. +//! every object with a raster effect is accompanied by an image rendered with the document's raster +//! effects settings (resolution, colour model, background, anti-aliasing, room around the art). +//! Shadows and outer glows only add the effect: the image (with the object's own area knocked out) +//! goes under the untouched vector object. Effects that change the object itself (inner glow, +//! feather, Gaussian blur) replace it with the image. use std::sync::Arc; -use serde_json::{Value, json}; -use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind}; -use vectorcraft_geom::{Affine, Rect}; +use serde_json::{Map, Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; +use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel}; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect}; use vectorcraft_render::effects::{self, RasterFx}; +use vectorcraft_render::{AntiAlias, RenderOptions}; use super::*; @@ -21,32 +25,114 @@ pub fn specs() -> Vec { "Document Raster Effects Settings…", ["Effect"], None, - "{resolution?: ppi (1–2400) | \"screen\" (72) | \"medium\" (150) | \"high\" (300)} resolution of raster effects in PDF export and Rasterize; no params → {resolution}", + "{resolution?: ppi (1–2400) | \"screen\" (72) | \"medium\" (150) | \"high\" (300), colorModel?: \"rgb\"|\"cmyk\" (the document's mode)|\"grayscale\"|\"bitmap\", background?: \"transparent\"|\"white\", antiAlias?: bool (off: hard edges), clippingMask?: bool (white background only under the art; Rasterize clips to the art), addAround?: pt (0–1000, room around the art), preserveSpotColors?: bool (stored)} how raster effects (shadows, glows, blurs, feathers) become images in PDF export and Expand Appearance, and the defaults of object.rasterize; one undo step; always → the settings", has_doc, settings )] } -fn settings(s: &mut Session, p: &Value) -> Result { - const C: &str = "document.rasterEffectsSettings"; - let ppi = match p.get("resolution") { - None | Some(Value::Null) => return Ok(json!({ "resolution": s.doc()?.doc.raster_effects_ppi })), - Some(Value::String(v)) => match v.to_ascii_lowercase().as_str() { +const C: &str = "document.rasterEffectsSettings"; + +/// What `document.rasterEffectsSettings` reports (and takes back). +fn settings_json(d: &Document) -> Value { + let r = &d.raster_effects; + let model = match (r.color_model, d.color_mode) { + (RasterColorModel::Document, ColorMode::Rgb) => "rgb", + (RasterColorModel::Document, ColorMode::Cmyk) => "cmyk", + (m, _) => m.id(), + }; + json!({ + "resolution": d.raster_effects_ppi, + "colorModel": model, + "background": r.background.id(), + "antiAlias": r.anti_alias, + "clippingMask": r.clipping_mask, + "addAround": r.add_around, + "preserveSpotColors": r.preserve_spot_colors, + }) +} + +fn resolution(v: &Value) -> Result { + let ppi = match v { + Value::String(v) => match v.to_ascii_lowercase().as_str() { "screen" => 72.0, "medium" => 150.0, "high" => 300.0, other => other.trim_end_matches("ppi").trim().parse::().map_err(|_| bad(C, format!("unknown resolution `{v}`")))?, }, - Some(v) => v.as_f64().ok_or_else(|| bad(C, "resolution must be a number or screen|medium|high"))?, + v => v.as_f64().ok_or_else(|| bad(C, "resolution must be a number or screen|medium|high"))?, }; if !(1.0..=2400.0).contains(&ppi) { return Err(bad(C, "resolution must be between 1 and 2400 ppi")); } - s.edit("Document Raster Effects Settings", |d, _| { - d.raster_effects_ppi = ppi; - Ok(()) - })?; - Ok(json!({ "resolution": ppi })) + Ok(ppi) +} + +/// `colorModel` for a document in `mode`: its own mode's name means the document's model. +pub(crate) fn color_model(v: &Value, mode: ColorMode, cmd: &str) -> Result { + let s = v.as_str().unwrap_or_default().to_ascii_lowercase(); + match (s.as_str(), mode) { + ("document", _) | ("rgb", ColorMode::Rgb) | ("cmyk", ColorMode::Cmyk) => Ok(RasterColorModel::Document), + ("grayscale" | "gray", _) => Ok(RasterColorModel::Grayscale), + ("bitmap", _) => Ok(RasterColorModel::Bitmap), + _ => { + let own = if mode == ColorMode::Rgb { "rgb" } else { "cmyk" }; + Err(bad(cmd, format!("colorModel must be {own} (the document's mode), grayscale or bitmap"))) + } + } +} + +/// `addAround`/`padding` in points. +pub(crate) fn add_around(v: &Value, cmd: &str, key: &str) -> Result { + v.as_f64().filter(|x| (0.0..=MAX_ADD_AROUND).contains(x)).ok_or_else(|| bad(cmd, format!("{key} must be 0–{MAX_ADD_AROUND} pt"))) +} + +fn flag(k: &str, v: &Value) -> Result { + v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false"))) +} + +fn settings(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 ppi, mut r) = (d.raster_effects_ppi, d.raster_effects.clone()); + // A null value leaves its setting as it is. + for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { + match k.as_str() { + "resolution" => ppi = resolution(v)?, + "colorModel" => r.color_model = color_model(v, d.color_mode, C)?, + "background" => r.background = super::docsetup::background_param(v, C)?, + "antiAlias" => r.anti_alias = flag(k, v)?, + "clippingMask" => r.clipping_mask = flag(k, v)?, + "addAround" => r.add_around = add_around(v, C, k)?, + "preserveSpotColors" => r.preserve_spot_colors = flag(k, v)?, + _ => return Err(bad(C, format!("unknown setting `{k}`"))), + } + } + if ppi != d.raster_effects_ppi || r != d.raster_effects { + s.edit("Document Raster Effects Settings", |d, _| { + d.raster_effects_ppi = ppi; + d.raster_effects = r; + Ok(()) + })?; + } + Ok(settings_json(&s.doc()?.doc)) +} + +/// `art` in a clip group clipped by `path` filled by `rule` (a clipping path that paints nothing). +pub(crate) fn clip_group(d: &mut Document, path: PathData, rule: FillRule, art: Node) -> Node { + let mut clip = super::pathops::shape_node(d, path, None); + clip.appearance = Appearance::basic(Paint::None, Paint::None, 0.0); + match &mut clip.kind { + NodeKind::Path { clipping, rule: r, .. } => (*clipping, *r) = (true, rule), + NodeKind::Compound { rule: r, .. } => *r = rule, + _ => {} + } + Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }) } fn raster_fx(n: &Node) -> Vec { @@ -61,7 +147,7 @@ fn needs(n: &Node) -> bool { } /// Render `nodes` alone over transparency: (premultiplied pixels, width, height). -fn render(doc: &Document, nodes: Vec, region: Rect, scale: f64) -> vectorcraft_render::Rendered { +fn render(doc: &Document, nodes: Vec, region: Rect, scale: f64, anti_alias: AntiAlias) -> vectorcraft_render::Rendered { let mut tmp = doc.clone(); let mut layer = Node::layer(NodeId(u64::MAX), "raster", vectorcraft_doc::LayerColor::Preset(0)); if let Some(ch) = layer.children_mut() { @@ -70,7 +156,7 @@ fn render(doc: &Document, nodes: Vec, region: Rect, scale: f64) -> vectorc tmp.layers = vec![Arc::new(layer)]; let mut r = vectorcraft_render::Renderer::new(); r.threads = 0; - r.render_region(&tmp, region, scale, false) + r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, ..Default::default() }) } /// Pixels per point of raster effects rendered as images (the document's raster effects @@ -83,17 +169,22 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 { /// with `out`'s resources at `scale` over its visual bounds and the reach of its raster effects /// (its own and its fills' and strokes'). With `knockout`, the coverage of that art is knocked out /// of the image, which then only holds what the effects add around it (a shadow to go under the -/// vector object). +/// vector object). The document's raster effects settings add room around it and finish its pixels. pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option { let b = whole.visual_bounds()?; - let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&whole.appearance.effects), f64::max) + 2.0; + let look = out.raster_effects.clone(); + let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&whole.appearance.effects), f64::max) + + 2.0 + + look.add_around; let b = b.inflate(reach, reach); // Whole pixels, and no larger than 64 Mpx. let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt()); let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); - let mut img = render(out, vec![whole.clone()], region, scale); + // Anti-alias off: hard edges (the effects themselves stay smooth). + let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None }; + let mut img = render(out, vec![whole.clone()], region, scale, anti_alias); if let Some(bare) = knockout { - let obj = render(out, vec![bare.clone()], region, scale); + let obj = render(out, vec![bare.clone()], region, scale, anti_alias); for (px, o) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(obj.pixels.as_chunks::<4>().0) { let keep = 1.0 - o[3] as f32 / 255.0; for c in px.iter_mut() { @@ -101,6 +192,8 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N } } } + // Document Raster Effects Settings: colour model, background. + look.finish_pixels(&mut img.pixels); // Can't encode: keep the object as it is (vector, effects ignored) rather than fail the export. let png = img.to_png().ok()?; let id = out.alloc_id(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b145e2d1..63ce7d18 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -997,6 +997,8 @@ mod tests_previewbounds; #[cfg(test)] mod tests_proxyitems; #[cfg(test)] +mod tests_rastersettings; +#[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_registration; diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs new file mode 100644 index 00000000..577c801c --- /dev/null +++ b/crates/engine/src/tests_rastersettings.rs @@ -0,0 +1,160 @@ +//! Effect → Document Raster Effects Settings (`document.rasterEffectsSettings`): the settings, the +//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; + +use super::*; + +const C: &str = "document.rasterEffectsSettings"; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + s +} + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A red rectangle with a drop shadow. +fn shadowed(s: &mut Session) -> NodeId { + let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r.0]})).unwrap(); + r +} + +/// The decoded pixels of the first raster effect image of `doc` flattened for output. +fn effect_image(doc: &Document) -> image::RgbaImage { + let flat = crate::flatten_raster_effects(doc).unwrap(); + let kids = flat.layers[0].children().unwrap(); + let NodeKind::Group { children, .. } = &kids[0].kind else { panic!("a shadow becomes a group") }; + let NodeKind::Image(im) = &children[0].kind else { panic!("the image goes under the object") }; + image::load_from_memory(&flat.images[&im.key].bytes).unwrap().to_rgba8() +} + +#[test] +fn settings_report_change_in_one_step_and_reject_junk() { + let mut s = session(); + let r = s.execute(C, &json!({})).unwrap(); + assert_eq!( + r, + json!({"resolution": 72.0, "colorModel": "rgb", "background": "transparent", "antiAlias": true, "clippingMask": false, "addAround": 0.0, "preserveSpotColors": true}) + ); + let r = s + .execute(C, &json!({"resolution": "high", "colorModel": "grayscale", "background": "white", "antiAlias": false, "clippingMask": true, "addAround": 36})) + .unwrap(); + assert_eq!((r["resolution"].as_f64(), r["colorModel"].as_str(), r["addAround"].as_f64()), (Some(300.0), Some("grayscale"), Some(36.0))); + assert_eq!(s.doc().unwrap().history.undo.len(), 1); + // The report fed back is no change. + s.execute(C, &r).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), 1); + for bad in [ + json!({"colorModel": "cmyk"}), + json!({"colorModel": "lab"}), + json!({"background": "pink"}), + json!({"antiAlias": "yes"}), + json!({"addAround": -1}), + json!({"addAround": 5000}), + json!({"resolution": 0}), + json!({"dpi": 300}), + ] { + assert!(s.execute(C, &bad).is_err(), "{bad}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!((d.raster_effects_ppi, &d.raster_effects), (72.0, &RasterEffectsSettings::default())); + // A CMYK document names its own mode. + let mut s = Session::new(); + s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap(); + assert_eq!(s.execute(C, &json!({"colorModel": "cmyk"})).unwrap()["colorModel"], "cmyk"); +} + +#[test] +fn a_white_background_makes_effect_images_opaque() { + let mut s = session(); + shadowed(&mut s); + let clear = effect_image(&s.doc().unwrap().doc); + assert_eq!(clear.get_pixel(0, 0)[3], 0, "transparent by default"); + s.execute(C, &json!({"background": "white"})).unwrap(); + let white = effect_image(&s.doc().unwrap().doc); + assert!(white.pixels().all(|p| p[3] == 255), "every pixel is opaque"); + assert_eq!(white.get_pixel(0, 0).0, [255, 255, 255, 255]); + // With a clipping mask, the white stays under the art only. + s.execute(C, &json!({"clippingMask": true})).unwrap(); + assert_eq!(effect_image(&s.doc().unwrap().doc).get_pixel(0, 0)[3], 0); +} + +#[test] +fn add_around_grows_the_effect_image() { + let mut s = session(); + shadowed(&mut s); + let before = effect_image(&s.doc().unwrap().doc); + s.execute(C, &json!({"addAround": 36})).unwrap(); + let after = effect_image(&s.doc().unwrap().doc); + // 36 pt on every side at 72 ppi. + assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72)); +} + +#[test] +fn the_color_model_and_anti_alias_reach_the_pixels() { + let mut s = session(); + shadowed(&mut s); + s.execute(C, &json!({"colorModel": "bitmap", "antiAlias": false})).unwrap(); + let img = effect_image(&s.doc().unwrap().doc); + assert!(img.pixels().filter(|p| p[3] > 0).all(|p| p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == 255)), "black and white"); + // Anti-alias off: Rasterize leaves no partly covered pixel on a circle's edge. + let e = id(&s.execute("shape.ellipse", &json!({"x": 180, "y": 180, "width": 50, "height": 50})).unwrap()); + s.execute("select.set", &json!({"ids": [e.0]})).unwrap(); + let out = s.execute("object.rasterize", &json!({"colorModel": "rgb"})).unwrap(); + let d = &s.doc().unwrap().doc; + let NodeKind::Image(im) = &d.node(id(&out)).unwrap().kind else { panic!("an image") }; + let img = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert!(img.pixels().all(|p| p[3] == 0 || p[3] == 255), "hard edges"); + assert!(img.pixels().any(|p| p[3] == 0) && img.pixels().any(|p| p[3] == 255)); +} + +#[test] +fn rasterize_takes_the_document_settings_as_defaults() { + let mut s = session(); + s.execute(C, &json!({"background": "white", "colorModel": "grayscale", "addAround": 10})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let out = s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(60), Some(60)), "10 pt around"); + let d = &s.doc().unwrap().doc; + let NodeKind::Image(im) = &d.node(id(&out)).unwrap().kind else { panic!("an image") }; + let img = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(0, 0).0, [255, 255, 255, 255], "white around the art"); + let p = img.get_pixel(30, 30).0; + assert!(p[0] == p[1] && p[1] == p[2] && p[0] < 128, "red in grayscale: {p:?}"); + // Params win; a clipping mask makes a clip group with the art's outline. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let out = s.execute("object.rasterize", &json!({"ppi": 72, "padding": 0, "clippingMask": true, "background": "transparent"})).unwrap(); + let d = &s.doc().unwrap().doc; + let NodeKind::Group { children, clip: true } = &d.node(id(&out)).unwrap().kind else { panic!("a clip group") }; + let clip = children[0].geometric_bounds().unwrap(); + assert!((clip.width() - 40.0).abs() < 1e-6 && matches!(children[1].kind, NodeKind::Image(_))); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [id(&out)]); + assert!(s.execute("object.rasterize", &json!({"colorModel": "lab"})).is_err()); +} + +#[test] +fn raster_settings_round_trip_and_old_files_load() { + let mut d = Document::new(10.0, 10.0); + d.raster_effects = + RasterEffectsSettings { color_model: RasterColorModel::Bitmap, background: Background::White, add_around: 12.0, ..Default::default() }; + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap(); + assert_eq!(back.raster_effects, d.raster_effects); + // Saved before the settings existed: only the resolution. + let old = Document::new(10.0, 10.0); + let bytes = vectorcraft_format::save_file(&old); + assert!(!String::from_utf8_lossy(&bytes).contains("raster_effects\""), "default settings aren't written"); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default())); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 2a012427..31a7030a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -33,6 +33,7 @@ pub mod new_swatch; mod path_ops; pub mod place; mod png_options; +pub mod raster_effects; pub mod recolor; pub mod saturate; mod save_changes; @@ -192,6 +193,7 @@ registry! { ImportPdf: [import_pdf::KIND] => import_pdf::SPEC, SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC, FileInfo: [file_info::KIND] => file_info::SPEC, + RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/raster_effects.rs b/crates/ui-egui/src/dialogs/raster_effects.rs new file mode 100644 index 00000000..7ef9e2ea --- /dev/null +++ b/crates/ui-egui/src/dialogs/raster_effects.rs @@ -0,0 +1,152 @@ +//! Effect → Document Raster Effects Settings: resolution, colour model, background, and the +//! anti-alias, clipping mask, room around the art and spot colour options. +//! +//! Fields: what `document.rasterEffectsSettings` reports (and takes back). OK runs it with them: +//! one undo step. On an error the dialog stays open. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use super::document_setup::{LABEL, check, choice}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +pub(super) const KIND: &str = "rasterEffectsSettings"; +const CMD: &str = "document.rasterEffectsSettings"; +/// The resolution presets: (ppi, label). +const RESOLUTIONS: [(f64, &str); 3] = [(72.0, "Screen (72 ppi)"), (150.0, "Medium (150 ppi)"), (300.0, "High (300 ppi)")]; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Document Raster Effects Settings".into(), + body, + confirm, + min_width: 440.0, + max_width: Some(440.0), + ..DialogSpec::FORM +}; + +/// Open the dialog on the active document's settings. +pub fn open(app: &mut VectorcraftApp) -> Result { + let fields = app.session.execute(CMD, &json!({})).map_err(|e| e.to_string())?; + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = app.run(CMD, Value::Object(d.fields.clone())); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + // Resolution: a preset, or Other with its own value. + let ppi = d.f64("resolution", 72.0); + let preset = RESOLUTIONS.iter().position(|(p, _)| (*p - ppi).abs() < 1e-9); + let mut labels: Vec<&str> = RESOLUTIONS.iter().map(|(_, l)| *l).collect(); + labels.push("Other"); + widgets::label_row(ui, "Resolution:", LABEL, |ui| { + if let Some(i) = widgets::dropdown(ui, "raster-resolution", labels[preset.unwrap_or(RESOLUTIONS.len())], &labels, 170.0) { + // Other keeps the current value to edit. + if let Some((p, _)) = RESOLUTIONS.get(i) { + d.fields.insert("resolution".into(), json!(p)); + } else if preset.is_some() { + d.fields.insert("resolution".into(), json!(ppi.round() + 1.0)); + } + } + if preset.is_none() + && let Some(v) = widgets::plain_field(ui, "raster-ppi", ppi, " ppi", 0, 80.0) + { + d.fields.insert("resolution".into(), json!(v)); + } + }); + ui.add_space(4.0); + let own = + if app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk) { ("cmyk", "CMYK") } else { ("rgb", "RGB") }; + choice(ui, d, "colorModel", "Color Model:", &[own, ("grayscale", "Grayscale"), ("bitmap", "Bitmap")]); + ui.add_space(6.0); + widgets::label_row(ui, "Background:", LABEL, |ui| { + let cur = d.str("background"); + for (id, label) in [("white", "White"), ("transparent", "Transparent")] { + if widgets::radio(ui, label, cur == id, true) { + d.fields.insert("background".into(), json!(id)); + } + ui.add_space(12.0); + } + }); + ui.add_space(10.0); + widgets::subheader(ui, "Options"); + check(ui, d, "antiAlias", "Anti-alias"); + check(ui, d, "clippingMask", "Create Clipping Mask"); + let unit = app.session.general_unit(); + let t = crate::theme::Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Add").color(t.text)); + if let Some(v) = widgets::num_field(ui, "raster-add-around", Some(d.f64("addAround", 0.0)), unit, 80.0) { + d.fields.insert("addAround".into(), json!(v)); + } + ui.label(egui::RichText::new("Around Object").color(t.text)); + }); + check(ui, d, "preserveSpotColors", "Preserve Spot Colors"); + ui.add_space(8.0); + super::form::caption(ui, "Changing these settings changes how raster effects look when exported, flattened or expanded."); + false +} + +#[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 raster_effects_settings_draw_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(); + crate::menus::invoke(&mut app, "document.rasterEffectsSettings", json!({})); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.kind.as_str(), d.fields["colorModel"].as_str()), (super::KIND, Some("rgb"))); + for (k, v) in [("resolution", json!(300)), ("background", json!("white")), ("addAround", json!(36)), ("colorModel", json!("grayscale"))] { + d.fields.insert(k.into(), v); + } + frame(&mut app); + // Other resolutions show their own field. + app.ui.dialog.as_mut().unwrap().fields.insert("resolution".into(), json!(200)); + frame(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.active().unwrap(); + let r = &st.doc.raster_effects; + assert_eq!((st.doc.raster_effects_ppi, r.add_around, r.background), (200.0, 36.0, vectorcraft_doc::Background::White)); + assert_eq!(r.color_model, vectorcraft_doc::RasterColorModel::Grayscale); + assert_eq!(st.history.undo.len(), 1); + // A bad value keeps the dialog open. + app.run("ui.rasterEffectsSettingsDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("addAround".into(), json!(-5)); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + crate::dialogs::cancel(&mut app); + // Rasterize… starts from these settings. + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + crate::menus::invoke(&mut app, "object.rasterize", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.fields["ppi"].as_f64(), d.fields["background"].as_str(), d.fields["addAround"].as_f64()), + (Some(200.0), Some("white"), Some(36.0)) + ); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert!(st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Image(_))))); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f4ee5c68..7d1db821 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -388,6 +388,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} 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", ), + ( + "ui.rasterEffectsSettingsDialog", + "Document Raster Effects Settings Dialog", + "", + "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -760,6 +766,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), + "ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app), _ => return None, }; Some(r) @@ -937,7 +944,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" | "ui.fileInfoDialog" => app.session.active().is_some(), + "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => 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()), @@ -1141,7 +1148,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Expand…", "ui.expandDialog"), c("Expand Appearance", "effect.expandAppearance"), c("Crop Image", "object.cropImage"), - cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent", "antiAlias": "art"})), + c("Rasterize…", "object.rasterize"), cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), cp( "Create Object Mosaic…", @@ -1737,20 +1744,22 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } - // Document Raster Effects Settings: a dialog with the current resolution. - if id == "document.rasterEffectsSettings" && p.as_object().is_none_or(|o| o.is_empty()) { - match app.session.execute(id, &json!({})) { - Ok(v) => { - let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Document Raster Effects Settings", "params": v})); - } - Err(e) => app.status(e.to_string()), + // Document Raster Effects Settings and File Info: their dialogs. + if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) { + let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) }; + if let Err(e) = r { + app.status(e); } 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); + // Rasterize…: its options, starting from the document's raster effects settings. + if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute("document.rasterEffectsSettings", &json!({})) { + Ok(v) => { + let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]}); + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params})); + } + Err(e) => app.status(e.to_string()), } return; } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b251d8b6..a3e46d76 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -246,3 +246,10 @@ File Info: File → File Info… (`file.info` from the menu or Cmd+Alt+Shift+I; `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. + +Document Raster Effects Settings: Effect → Document Raster Effects Settings… (`ui.rasterEffectsSettingsDialog` for +agents) opens the `rasterEffectsSettings` dialog. Its fields are what `document.rasterEffectsSettings` reports +(`resolution`, `colorModel`, `background`, `antiAlias`, `clippingMask`, `addAround` in pt, `preserveSpotColors`); +`ui.dialog.confirm` runs it with them as one undo step; a bad value keeps the dialog open. Object → Rasterize… opens a +`command` dialog for `object.rasterize` whose fields (`ppi`, `colorModel`, `background`, `antiAlias`, `clippingMask`, +`addAround`) start from these settings. diff --git a/docs/mcp.md b/docs/mcp.md index 82eb3ce8..0e63ce74 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -907,3 +907,17 @@ 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"}}} ``` + +## Document Raster Effects Settings + +`document.rasterEffectsSettings` holds how raster effects (shadows, glows, blurs, feathers) become images when PDF +export or Expand Appearance renders them: `resolution` (ppi, also New Document's `rasterEffectsPpi`), `colorModel` +(the document's `rgb`/`cmyk`, `grayscale` or `bitmap`), `background` (`white` makes the images opaque), `antiAlias` +(off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (points of room around the art) +and `preserveSpotColors` (stored). They are also the defaults of `object.rasterize`, whose params override them (its +`clippingMask` puts the image in a clip group with the art's outline). No params reports them; any of them changes them +in one undo step. + +```json +{"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} +```