diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d5c6d5e3..f0e468d3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -10,10 +10,12 @@ pub mod graph; pub mod hit; pub mod live; pub mod marks; +pub mod metadata; pub mod node; pub mod overprint; pub mod pattern; mod pixels; +pub mod rastersettings; mod reach; pub mod selection; pub mod setup; @@ -45,11 +47,13 @@ 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}; 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; @@ -476,6 +480,13 @@ 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, + /// 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 { @@ -533,6 +544,8 @@ impl Document { spot_use_lab: true, 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/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/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/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/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/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/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 3e38a9d9..63ce7d18 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; @@ -995,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_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/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/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..31a7030a 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; @@ -32,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; @@ -190,6 +192,8 @@ 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, + 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/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..7d1db821 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -382,6 +382,18 @@ 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", + ), + ( + "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). @@ -753,6 +765,8 @@ 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) @@ -930,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" => 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()), @@ -1134,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…", @@ -1730,11 +1744,20 @@ 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!({})) { + // 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; + } + // 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 _ = app.run("ui.paramDialog", json!({"command": id, "label": "Document Raster Effects Settings", "params": 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()), } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e5b4cace..a3e46d76 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -239,3 +239,17 @@ 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. + +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 ffd98e2e..0e63ce74 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -892,3 +892,32 @@ 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"}}} +``` + +## 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}}} +```