Merge pull request #77 from storytold/pr/m4-file-info-raster-settings

M4: File Info in SVG, PDF and PNG; Document Raster Effects Settings (M4.55, M4.56)
This commit is contained in:
BFlat 2026-10-05 10:05:40 +07:00 committed by GitHub
commit 0c65fe77f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1578 additions and 110 deletions

View File

@ -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))));

151
crates/doc/src/metadata.rs Normal file
View File

@ -0,0 +1,151 @@
//! File → File Info: the document's descriptive metadata (author, description, keywords, rating,
//! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
//! before File Info existed load unchanged.
use serde::{Deserialize, Serialize};
/// File Info → Copyright Status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CopyrightStatus {
#[default]
Unknown,
Copyrighted,
PublicDomain,
}
impl CopyrightStatus {
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
pub fn id(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "unknown",
CopyrightStatus::Copyrighted => "copyrighted",
CopyrightStatus::PublicDomain => "publicDomain",
}
}
pub fn label(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "Unknown",
CopyrightStatus::Copyrighted => "Copyrighted",
CopyrightStatus::PublicDomain => "Public Domain",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s) || c.label().eq_ignore_ascii_case(s))
}
}
/// Highest File Info rating (stars).
pub const MAX_RATING: u8 = 5;
/// File → File Info.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocMetadata {
pub author: String,
/// The author's position or job title.
pub author_title: String,
pub description: String,
/// Keywords, each once, in the order they were added.
pub keywords: Vec<String>,
/// 0 (unrated) to [`MAX_RATING`] stars.
pub rating: u8,
pub copyright_status: CopyrightStatus,
pub copyright_notice: String,
/// Where to read more about the copyright.
pub copyright_url: String,
/// When the document was created, as Unix seconds (UTC).
pub created: Option<i64>,
/// When the document was last saved, as Unix seconds (UTC).
pub modified: Option<i64>,
}
impl DocMetadata {
/// The PNG text entries (registered keywords where one fits) for a document titled `title`:
/// only the non-empty ones.
pub fn png_text<'a>(&'a self, title: &'a str) -> Vec<(&'static str, std::borrow::Cow<'a, str>)> {
let mut out: Vec<(&'static str, std::borrow::Cow<'a, str>)> = vec![];
let mut push = |k: &'static str, v: std::borrow::Cow<'a, str>| {
if !v.trim().is_empty() {
out.push((k, v));
}
};
push("Title", title.into());
push("Author", self.author.as_str().into());
push("Description", self.description.as_str().into());
push("Keywords", self.keywords.join(", ").into());
push("Copyright", self.copyright_notice.as_str().into());
push("Copyright URL", self.copyright_url.as_str().into());
push("Creation Time", self.created.map(iso8601).unwrap_or_default().into());
out
}
}
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
#[cfg(not(target_arch = "wasm32"))]
pub fn now_unix() -> Option<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok())
}
#[cfg(target_arch = "wasm32")]
pub fn now_unix() -> Option<i64> {
None
}
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic
/// Gregorian; years clamped to 0–9999).
pub fn civil(t: i64) -> [i64; 6] {
// Clamp first so every step stays in range for any input.
let t = t.clamp(-62_167_219_200, 253_402_300_799);
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
[year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60]
}
/// Unix seconds → `2026-10-05T14:03:00Z`.
pub fn iso8601(t: i64) -> String {
let [y, mo, d, h, mi, s] = civil(t);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates_and_statuses() {
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
assert_eq!(civil(i64::MIN)[0], 0);
assert_eq!(civil(i64::MAX)[0], 9999);
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("maybe"), None);
}
#[test]
fn png_text_skips_empty_entries() {
let m = DocMetadata { author: "Ada".into(), keywords: vec!["a".into(), "b".into()], created: Some(0), ..Default::default() };
let t: Vec<(&str, String)> = m.png_text("Poster").into_iter().map(|(k, v)| (k, v.into_owned())).collect();
assert_eq!(
t,
[
("Title", "Poster".to_string()),
("Author", "Ada".into()),
("Keywords", "a, b".into()),
("Creation Time", "1970-01-01T00:00:00Z".into())
]
);
assert!(DocMetadata::default().png_text("").is_empty());
}
}

View File

@ -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() });
}
}

View File

@ -158,9 +158,9 @@ pub fn specs() -> Vec<CommandSpec> {
"File Info…",
["File"],
Some("Cmd+Alt+Shift+I"),
"{title?} set the document title; always → {title, colorMode, units, artboards, objects}",
"{title?, author?, authorTitle?, description?, keywords?: [string…]|\"a, b\" (each once), rating?: 0–5, copyrightStatus?: \"unknown\"|\"copyrighted\"|\"publicDomain\", copyrightNotice?, copyrightUrl?} set the File Info in one undo step (SVG with metadata, PDF and PNG exports carry it); no params → {title, author, authorTitle, description, keywords, rating, copyrightStatus, copyrightNotice, copyrightUrl, created, modified (ISO 8601 UTC or null; read-only), colorMode, units, artboards, objects}",
has_doc,
file_info
super::fileinfo::file_info
),
]
}
@ -484,21 +484,3 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
}
}
}
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(t) = str_param(p, "title") {
let t = t.to_string();
s.edit("File Info", |d, _| {
d.title = t;
Ok(())
})?;
}
let d = &s.doc()?.doc;
Ok(json!({
"title": d.title,
"colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" },
"units": d.units.label(),
"artboards": d.artboards.len(),
"objects": d.node_count(),
}))
}

View File

@ -0,0 +1,142 @@
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
//! SVG (with `metadata`), PDF and PNG writers carry it.
use serde_json::{Map, Value, json};
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
use vectorcraft_doc::{ColorMode, CopyrightStatus, DocMetadata, Document};
use super::*;
const C: &str = "file.info";
/// Longest text field, in characters.
const MAX_TEXT: usize = 65_536;
/// Longest keyword, in characters.
const MAX_KEYWORD: usize = 256;
const MAX_KEYWORDS: usize = 1000;
/// Values `file.info` reports but doesn't take (fed back, they are ignored).
const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artboards", "objects"];
/// What `file.info` reports (and takes back).
pub(crate) fn info_json(d: &Document) -> Value {
let m = &d.metadata;
json!({
"title": d.title,
"author": m.author,
"authorTitle": m.author_title,
"description": m.description,
"keywords": m.keywords,
"rating": m.rating,
"copyrightStatus": m.copyright_status.id(),
"copyrightNotice": m.copyright_notice,
"copyrightUrl": m.copyright_url,
"created": m.created.map(iso8601),
"modified": m.modified.map(iso8601),
"colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" },
"units": d.units.label(),
"artboards": d.artboards.len(),
"objects": d.node_count(),
})
}
pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
let empty = Map::new();
let o = match p {
Value::Null => &empty,
Value::Object(o) => o,
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
let (mut title, mut m) = (d.title.clone(), d.metadata.clone());
for (k, v) in o {
apply(&mut title, &mut m, k, v)?;
}
let d = &s.doc()?.doc;
if title != d.title || m != d.metadata {
s.edit("File Info", |d, _| {
d.title = title;
d.metadata = m;
Ok(())
})?;
}
Ok(info_json(&s.doc()?.doc))
}
fn apply(title: &mut String, m: &mut DocMetadata, k: &str, v: &Value) -> Result<()> {
match k {
"title" => {
*title = text(k, v, false)?;
if title.is_empty() {
return Err(bad(C, "title can't be empty"));
}
}
"author" => m.author = text(k, v, false)?,
"authorTitle" => m.author_title = text(k, v, false)?,
"description" => m.description = text(k, v, true)?,
"keywords" => m.keywords = keywords(v)?,
"rating" => {
m.rating = v
.as_u64()
.filter(|r| *r <= u64::from(MAX_RATING))
.and_then(|r| u8::try_from(r).ok())
.ok_or_else(|| bad(C, format!("rating must be a whole number from 0 to {MAX_RATING}")))?
}
"copyrightStatus" => {
m.copyright_status =
v.as_str().and_then(CopyrightStatus::parse).ok_or_else(|| bad(C, "copyrightStatus must be unknown, copyrighted or publicDomain"))?
}
"copyrightNotice" => m.copyright_notice = text(k, v, true)?,
"copyrightUrl" => {
let url = text(k, v, false)?;
if url.chars().any(char::is_whitespace) || url.chars().count() > 2048 {
return Err(bad(C, "copyrightUrl must be a URL (no spaces, at most 2048 characters)"));
}
m.copyright_url = url;
}
k if READ_ONLY.contains(&k) => {}
_ => return Err(bad(C, format!("unknown File Info field `{k}`"))),
}
Ok(())
}
/// A trimmed text value (`null` clears it); line breaks only where `multiline`, no other control
/// characters.
fn text(k: &str, v: &Value, multiline: bool) -> Result<String> {
let s = match v {
Value::Null => "",
Value::String(s) => s.trim(),
_ => return Err(bad(C, format!("{k} must be text"))),
};
if s.chars().count() > MAX_TEXT {
return Err(bad(C, format!("{k} is too long (at most {MAX_TEXT} characters)")));
}
if s.chars().any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t'))) {
return Err(bad(C, format!("{k} has control characters{}", if multiline { "" } else { " or line breaks" })));
}
Ok(s.to_string())
}
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
/// case), empty ones dropped.
fn keywords(v: &Value) -> Result<Vec<String>> {
let parts: Vec<String> = match v {
Value::Null => vec![],
Value::String(s) => s.split([',', ';']).map(str::to_string).collect(),
Value::Array(a) => a.iter().map(|x| text("keywords", x, false)).collect::<Result<_>>()?,
_ => return Err(bad(C, "keywords must be a list of words or a comma-separated string")),
};
let mut seen = std::collections::HashSet::new();
let mut out: Vec<String> = vec![];
for k in parts.iter().map(|k| k.trim()).filter(|k| !k.is_empty()) {
if k.chars().count() > MAX_KEYWORD || k.chars().any(char::is_control) {
return Err(bad(C, format!("keywords must be at most {MAX_KEYWORD} characters, without control characters")));
}
if seen.insert(k.to_lowercase()) {
out.push(k.to_string());
}
if out.len() > MAX_KEYWORDS {
return Err(bad(C, format!("at most {MAX_KEYWORDS} keywords")));
}
}
Ok(out)
}

View File

@ -272,7 +272,14 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
check_format_size(f, region.width() * scale, region.height() * scale)?;
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
enc.files.push((Some(b), renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?));
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
// File Info as PNG text chunks.
let bytes = if format == RasterFormat::Png {
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
} else {
bytes
};
enc.files.push((Some(b), bytes));
}
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));

View File

@ -18,6 +18,7 @@ mod export;
mod load;
pub mod pdf;
mod pdfimport;
pub mod pngtext;
pub mod ppi;
mod save;
mod svg;
@ -30,7 +31,7 @@ use load::err;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image};
pub use pdfimport::{LoadOptions, page_document};
pub use save::{save_encoding, save_format};
pub use save::{save_encoding, save_format, stamp_save_dates};
pub use svg::options_map as svg_options;
use super::*;

View File

@ -0,0 +1,47 @@
//! PNG text chunks (`tEXt`, uncompressed `iTXt`) read back; the PNG writer adds them
//! ([`vectorcraft_render::encode::png::with_text`]).
/// The `tEXt` and uncompressed `iTXt` entries of a PNG before its image data: (keyword, text).
pub fn text(png: &[u8]) -> Vec<(String, String)> {
if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
return vec![];
}
super::ppi::png_chunks(png)
.take_while(|(ty, _)| *ty != b"IDAT")
.filter_map(|(ty, data)| {
let nul = data.iter().position(|b| *b == 0)?;
let keyword: String = data.get(..nul)?.iter().map(|b| char::from(*b)).collect();
let rest = data.get(nul + 1..)?;
let text = match ty {
b"tEXt" => rest.iter().map(|b| char::from(*b)).collect(),
// Compression flag 0, method, then the language tag and translated keyword.
b"iTXt" if rest.first() == Some(&0) => {
let mut parts = rest.get(2..)?.splitn(3, |b| *b == 0);
let (_, _, text) = (parts.next()?, parts.next()?, parts.next()?);
String::from_utf8_lossy(text).into_owned()
}
_ => return None,
};
Some((keyword, text))
})
.collect()
}
#[cfg(test)]
mod tests {
use vectorcraft_render::encode::png::with_text;
use super::*;
#[test]
fn text_chunks_round_trip_and_stay_valid() {
let png = vectorcraft_render::Rendered { width: 2, height: 1, pixels: vec![255; 8] }.to_png().unwrap();
let out = with_text(png.clone(), &[("Title", "Café".into()), ("Author", "Łukasz".into()), ("bad\u{1}", "x".into())]);
assert_eq!(text(&out), [("Title".to_string(), "Café".to_string()), ("Author".into(), "Łukasz".into())]);
// Still a PNG every decoder reads (the CRCs check out).
let img = image::load_from_memory(&out).unwrap();
assert_eq!((img.width(), img.height()), (2, 1));
assert_eq!(with_text(b"not a png".to_vec(), &[("Title", "x".into())]), b"not a png");
assert_eq!(with_text(png.clone(), &[]), png);
}
}

View File

@ -50,7 +50,7 @@ fn per_metre(ppm: u32) -> f64 {
}
/// PNG chunks after the signature: (type, data).
fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
pub(super) fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
let mut at = 8;
std::iter::from_fn(move || {
let len = be32(b, at)? as usize;

View File

@ -39,10 +39,22 @@ pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, V
Ok((enc, opts))
}
/// File Info's dates for a save to a file: the modified date (and the created date, when it has
/// none) become now. Not an undo step; the web build, without a clock, leaves them.
pub fn stamp_save_dates(st: &mut DocState) {
let Some(now) = vectorcraft_doc::metadata::now_unix() else { return };
let d = std::sync::Arc::make_mut(&mut st.doc);
d.metadata.created.get_or_insert(now);
d.metadata.modified = Some(now);
}
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.save";
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
let f = save_format(str_param(p, "format"), path.as_deref()).map_err(|e| bad(C, e))?;
if path.is_some() {
stamp_save_dates(s.doc_mut()?);
}
let st = s.doc()?;
let (enc, opts) = save_encoding(st, f, p)?;
let Some(path) = path else {

View File

@ -54,7 +54,12 @@ pub const OPTIONS: &[FormatOption] = &[
default: "false",
description: "embed the native document in <metadata> so VectorCraft reopens the SVG with nothing lost",
},
FormatOption { name: "metadata", ty: "boolean", default: "false", description: "write <metadata> with the title and format (Dublin Core)" },
FormatOption {
name: "metadata",
ty: "boolean",
default: "false",
description: "write <metadata> with the title, format and File Info (Dublin Core)",
},
FormatOption {
name: "fewerTspans",
ty: "boolean",

View File

@ -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`.

View File

@ -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<CommandSpec> {
"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<u8>, 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<u8>, 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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) });

View File

@ -17,6 +17,7 @@ mod draw2;
mod edit;
mod effectcmd;
pub mod expand;
pub(crate) mod fileinfo;
pub mod fileio;
pub mod flatten;
mod fonts;

View File

@ -558,6 +558,7 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
Some(t) => t.to_string(),
None => s.next_untitled(),
};
d.metadata.created = vectorcraft_doc::metadata::now_unix();
let i = s.add_document(d, None);
// Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both
// are views of the whole app, so Default leaves them as they are.

View File

@ -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<CommandSpec> {
"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<Value> {
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<f64> {
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::<f64>().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<RasterColorModel> {
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<f64> {
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<bool> {
v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false")))
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let empty = Map::new();
let o = match p {
Value::Null => &empty,
Value::Object(o) => o,
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
let (mut 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<RasterFx> {
@ -61,7 +147,7 @@ fn needs(n: &Node) -> bool {
}
/// Render `nodes` alone over transparency: (premultiplied pixels, width, height).
fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64) -> vectorcraft_render::Rendered {
fn render(doc: &Document, nodes: Vec<Node>, 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<Node>, 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<Node> {
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();

View File

@ -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;

View File

@ -0,0 +1,163 @@
//! File → File Info (`file.info`): editing as one undo step, the native round trip, and the PDF,
//! PNG and SVG metadata it writes.
use serde_json::{Value, json};
use vectorcraft_doc::{CopyrightStatus, DocMetadata};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "title": "Poster"})).unwrap();
s
}
fn info() -> Value {
json!({
"author": "Ada Lovelace",
"authorTitle": "Designer",
"description": "A poster\nfor the fair",
"keywords": "fair, Poster; print, poster",
"rating": 4,
"copyrightStatus": "copyrighted",
"copyrightNotice": "(c) 2026 Ada",
"copyrightUrl": "https://example.com/rights",
})
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
#[test]
fn file_info_is_one_undo_step_and_reports_everything() {
let mut s = session();
let r = s.execute("file.info", &info()).unwrap();
assert_eq!(r["keywords"], json!(["fair", "Poster", "print"]), "keywords split, trimmed, each once ignoring case");
assert_eq!((r["rating"].as_u64(), r["copyrightStatus"].as_str()), (Some(4), Some("copyrighted")));
assert_eq!(r["description"], "A poster\nfor the fair");
assert!(r["created"].as_str().is_some_and(|c| c.ends_with('Z')), "a new document has its creation date: {}", r["created"]);
assert!(r["modified"].is_null(), "never saved");
assert_eq!(r["title"], "Poster");
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), 1);
assert_eq!(st.doc.metadata.author, "Ada Lovelace");
// The report fed back changes nothing (read-only values are ignored).
let again = s.execute("file.info", &r).unwrap();
assert_eq!(again, r);
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
s.execute("edit.undo", &json!({})).unwrap();
let m = &s.doc().unwrap().doc.metadata;
assert_eq!((m.author.as_str(), m.keywords.len()), ("", 0));
// Clearing a field with null.
s.execute("edit.redo", &json!({})).unwrap();
s.execute("file.info", &json!({"author": null, "keywords": []})).unwrap();
let m = &s.doc().unwrap().doc.metadata;
assert_eq!((m.author.as_str(), m.keywords.len(), m.rating), ("", 0, 4));
}
#[test]
fn junk_is_rejected() {
let mut s = session();
for bad in [
json!({"rating": 9}),
json!({"rating": -1}),
json!({"rating": "five"}),
json!({"keywords": 5}),
json!({"keywords": ["ok", 3]}),
json!({"copyrightStatus": "maybe"}),
json!({"copyrightUrl": "not a url"}),
json!({"author": "two\nlines"}),
json!({"description": "bell\u{7}"}),
json!({"title": " "}),
json!({"author": 3}),
json!({"favouriteColour": "blue"}),
json!([1, 2]),
json!({"keywords": "x".repeat(300)}),
] {
assert!(s.execute("file.info", &bad).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), 0);
assert_eq!(s.doc().unwrap().doc.metadata, DocMetadata { created: s.doc().unwrap().doc.metadata.created, ..Default::default() });
}
#[test]
fn file_info_round_trips_and_old_files_load() {
let mut s = session();
s.execute("file.info", &info()).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap();
assert_eq!(back.metadata, d.metadata);
assert_eq!(back.metadata.copyright_status, CopyrightStatus::Copyrighted);
// A document saved before File Info existed has none.
let mut old = vectorcraft_doc::Document::new(10.0, 10.0);
old.metadata = DocMetadata::default();
let bytes = vectorcraft_format::save_file(&old);
assert!(!String::from_utf8_lossy(&bytes).contains("\"metadata\""), "default File Info isn't written");
assert_eq!(vectorcraft_format::load(&bytes).unwrap().metadata, DocMetadata::default());
}
#[test]
fn saving_to_a_file_stamps_the_modified_date() {
let mut s = session();
let created = s.doc().unwrap().doc.metadata.created;
let path = std::env::temp_dir().join(format!("vc-fileinfo-{}.vectorcraft", std::process::id()));
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.metadata.modified.is_some() && st.doc.metadata.created == created);
assert!(!st.is_dirty(), "the stamped document is the saved one");
let back = vectorcraft_format::load(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(back.metadata.modified, st.doc.metadata.modified);
let _ = std::fs::remove_file(&path);
assert!(s.execute("file.info", &json!({})).unwrap()["modified"].is_string());
}
#[test]
fn pdf_info_carries_author_subject_and_keywords() {
let mut s = session();
s.execute("file.info", &info()).unwrap();
let pdf = crate::export_pdf(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
let text = String::from_utf8_lossy(&pdf);
// (The description has a line break, so it is written as UTF-16.)
for want in ["/Author(Ada Lovelace)", "/Keywords(fair, Poster, print)", "/Title(Poster)", "/Subject<FEFF0041"] {
assert!(text.contains(want), "PDF Info has {want}");
}
// XMP too.
assert!(text.contains("<dc:creator>") && text.contains("Ada Lovelace"));
}
#[test]
fn png_exports_carry_text_chunks() {
let mut s = session();
s.execute("file.info", &info()).unwrap();
let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
let text = crate::cmd::fileio::pngtext::text(&png);
let get = |k: &str| text.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str());
assert_eq!(get("Title"), Some("Poster"));
assert_eq!(get("Author"), Some("Ada Lovelace"));
assert_eq!(get("Keywords"), Some("fair, Poster, print"));
assert_eq!(get("Copyright"), Some("(c) 2026 Ada"));
assert!(get("Creation Time").is_some_and(|t| t.ends_with('Z')));
assert!(image::load_from_memory(&png).is_ok(), "still a valid PNG");
}
#[test]
fn svg_metadata_carries_file_info_as_dublin_core() {
let mut s = session();
s.execute("file.info", &info()).unwrap();
let svg = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg", "metadata": true})).unwrap())).unwrap();
for want in [
"<dc:title>Poster</dc:title>",
"<dc:creator>Ada Lovelace</dc:creator>",
"<dc:subject>fair</dc:subject>",
"<dc:subject>print</dc:subject>",
"<dc:rights>(c) 2026 Ada</dc:rights>",
"<dc:rights>https://example.com/rights</dc:rights>",
"<desc>A poster\nfor the fair</desc>",
] {
assert!(svg.contains(want), "SVG has {want}");
}
// Without the option: no <metadata>, but the title and description stay.
let plain = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg"})).unwrap())).unwrap();
assert!(!plain.contains("<metadata>") && plain.contains("<desc>"));
}

View File

@ -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()));
}

View File

@ -69,7 +69,19 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
if !title.is_empty() {
meta = meta.title(title);
}
if let Some(t) = opts.created.or_else(now_unix) {
// File Info.
let info = &doc.metadata;
let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string);
if let Some(a) = given(&info.author) {
meta = meta.authors(vec![a]);
}
if let Some(d) = given(&info.description) {
meta = meta.description(d);
}
if !info.keywords.is_empty() {
meta = meta.keywords(info.keywords.clone());
}
if let Some(t) = opts.created.or_else(vectorcraft_doc::metadata::now_unix) {
meta = meta.creation_date(date_time(t));
}
pdf.set_metadata(meta);
@ -175,35 +187,15 @@ fn number(digits: &[u8]) -> Option<u32> {
std::str::from_utf8(digits).ok()?.parse().ok()
}
#[cfg(not(target_arch = "wasm32"))]
fn now_unix() -> Option<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64)
}
#[cfg(target_arch = "wasm32")]
fn now_unix() -> Option<i64> {
None
}
/// Unix seconds (UTC) → krilla date (civil-from-days, proleptic Gregorian).
/// Unix seconds (UTC) → krilla date.
fn date_time(t: i64) -> krilla::metadata::DateTime {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
krilla::metadata::DateTime::new(year.clamp(0, 9999) as u16)
let [year, month, day, hour, minute, second] = vectorcraft_doc::metadata::civil(t);
krilla::metadata::DateTime::new(year as u16)
.month(month as u8)
.day(day as u8)
.hour((secs / 3600) as u8)
.minute((secs / 60 % 60) as u8)
.second((secs % 60) as u8)
.hour(hour as u8)
.minute(minute as u8)
.second(second as u8)
.utc_offset_hour(0)
.utc_offset_minute(0)
}

View File

@ -84,6 +84,44 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Ve
Ok(out)
}
/// `png` (a PNG file) with a text chunk per `(keyword, text)` right after its header: `tEXt`, or
/// uncompressed `iTXt` (UTF-8) for text beyond Latin-1. Keywords are 1–79 printable ASCII
/// characters (others are skipped). Unchanged when it doesn't start with a PNG header.
pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
// The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first.
const HEADER: usize = 8 + 12 + 13;
if entries.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER {
return png;
}
let mut out = Vec::with_capacity(png.len() + entries.iter().map(|(k, v)| k.len() + v.len() + 20).sum::<usize>());
out.extend_from_slice(&png[..HEADER]);
for (keyword, text) in entries {
if keyword.is_empty() || keyword.len() > 79 || !keyword.bytes().all(|b| (32..=126).contains(&b)) {
continue;
}
let mut data = keyword.as_bytes().to_vec();
data.push(0);
let latin1: Option<Vec<u8>> = text.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect();
let ty = match latin1 {
Some(bytes) => {
data.extend(bytes);
b"tEXt"
}
None => {
// Uncompressed, no language tag, no translated keyword.
data.extend_from_slice(&[0, 0, 0, 0]);
data.extend_from_slice(text.as_bytes());
b"iTXt"
}
};
if u32::try_from(data.len()).is_ok() {
chunk(&mut out, ty, &data);
}
}
out.extend_from_slice(&png[HEADER..]);
out
}
/// Append one chunk: length, type, data, CRC of type and data.
fn chunk(out: &mut Vec<u8>, ty: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());

View File

@ -112,6 +112,11 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
out.push_str(&w.indent(1));
out.push_str(&format!("<title>{}</title>{nl}", xml_escape(&doc.title)));
}
// File Info's description.
if !doc.metadata.description.trim().is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<desc>{}</desc>{nl}", xml_escape(&doc.metadata.description)));
}
// The editing data carries a hash of the markup around its `<metadata>`.
let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "</svg>", nl])));
w.metadata(&mut out, native);
@ -301,12 +306,17 @@ fn pin_to_page(n: &mut Node, m: Affine) {
}
}
/// The Dublin Core terms File Info writes to `<metadata>`.
/// The Dublin Core terms File Info writes to `<metadata>`: the format, title, author (creator),
/// description, each keyword (subject), copyright notice and URL (rights) and created date, the
/// empty ones left out.
fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> {
let info = &doc.metadata;
let mut terms = vec![("format", "image/svg+xml".to_string())];
if !doc.title.is_empty() {
terms.push(("title", doc.title.clone()));
}
let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default();
let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())];
let keywords = info.keywords.iter().map(|k| ("subject", k.as_str()));
terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string())));
terms
}

View File

@ -139,7 +139,7 @@ pub struct ExportOptions {
/// Embed the native document (passed to [`export_full`]) in `<metadata>` so VectorCraft
/// reopens the SVG with nothing lost (see [`editing`]).
pub preserve_editing: bool,
/// Write `<metadata>` with the document's Dublin Core title and format.
/// Write `<metadata>` with the document's title, format and File Info (Dublin Core).
pub metadata: bool,
/// One positioned `<tspan>` per line of type instead of one per style run, tab stop and
/// justified word (smaller; viewers then space the line with their own font metrics).

View File

@ -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));

View File

@ -0,0 +1,234 @@
//! File → File Info: the document title, author, description, rating, keywords (chips) and
//! copyright, with the created and modified dates.
//!
//! Fields: what `file.info` reports (and takes back) plus `__keyword`, the keyword being typed
//! (Enter or Add turns it into chips; OK keeps it too). OK runs `file.info` with them: one undo
//! step. On an error the dialog stays open.
use serde_json::{Value, json};
use vectorcraft_doc::CopyrightStatus;
use vectorcraft_doc::metadata::MAX_RATING;
use super::document_setup::{LABEL, choice};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, icons, widgets};
pub(super) const KIND: &str = "fileInfo";
/// The keyword being typed.
const PENDING: &str = "__keyword";
/// Width of the text fields.
const FIELD: f32 = 320.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "File Info".into(), body, confirm, min_width: 500.0, max_width: Some(500.0), ..DialogSpec::FORM };
/// Open File Info on the active document's.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let mut fields = app.session.execute("file.info", &json!({})).map_err(|e| e.to_string())?;
fields[PENDING] = json!("");
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut d = d.clone();
// A keyword typed but not added yet is kept.
add_keywords(&mut d);
let r = app.run("file.info", form::params(&d));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
/// The keywords in `d`.
fn keywords(d: &Dialog) -> Vec<String> {
d.fields.get("keywords").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
}
/// Move the typed keywords (comma- or semicolon-separated) into the list, each once.
fn add_keywords(d: &mut Dialog) {
let mut list = keywords(d);
for k in d.str(PENDING).split([',', ';']).map(str::trim).filter(|k| !k.is_empty()) {
if !list.iter().any(|o| o.to_lowercase() == k.to_lowercase()) {
list.push(k.to_string());
}
}
d.fields.insert("keywords".into(), json!(list));
d.fields.insert(PENDING.into(), json!(""));
}
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
for (key, label) in [("title", "Document Title:"), ("author", "Author:"), ("authorTitle", "Author Title:")] {
widgets::label_row(ui, label, LABEL, |ui| {
form::text(ui, d, key, FIELD);
});
ui.add_space(4.0);
}
top_row(ui, "Description:", |ui| {
form::text_area(ui, d, "description", FIELD, 3);
});
ui.add_space(4.0);
widgets::label_row(ui, "Rating:", LABEL, |ui| rating(ui, d));
ui.add_space(4.0);
top_row(ui, "Keywords:", |ui| {
ui.vertical(|ui| {
ui.set_max_width(FIELD + 12.0);
chips(ui, d);
ui.horizontal(|ui| {
let r = form::text_edit(ui, d, PENDING, FIELD - 70.0);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if enter {
// Enter adds the keyword (it doesn't press OK) and keeps typing in the field.
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
r.request_focus();
}
let typed = !d.str(PENDING).trim().is_empty();
if (widgets::flat_button(ui, "Add", 56.0).clicked() || enter) && typed {
add_keywords(d);
}
});
form::caption(ui, "Separate several keywords with commas.");
});
});
ui.add_space(10.0);
widgets::subheader(ui, "Copyright");
let statuses: Vec<(&str, &str)> = CopyrightStatus::ALL.iter().map(|c| (c.id(), c.label())).collect();
choice(ui, d, "copyrightStatus", "Copyright Status:", &statuses);
ui.add_space(4.0);
top_row(ui, "Copyright Notice:", |ui| {
form::text_area(ui, d, "copyrightNotice", FIELD, 2);
});
ui.add_space(4.0);
widgets::label_row(ui, "Copyright Info URL:", LABEL, |ui| {
form::text(ui, d, "copyrightUrl", FIELD);
});
ui.add_space(10.0);
widgets::subheader(ui, "Dates");
for (key, label) in [("created", "Created:"), ("modified", "Modified:")] {
// ISO 8601 UTC from the engine, shown as "2026-10-05 14:03:00 UTC".
let shown = d
.fields
.get(key)
.and_then(Value::as_str)
.map_or_else(|| "—".to_string(), |s| format!("{} UTC", s.replace('T', " ").trim_end_matches('Z')));
widgets::label_row(ui, label, LABEL, |ui| {
ui.label(egui::RichText::new(shown).color(t.text_dim));
});
}
false
}
/// A form row whose label sits at the top of a taller field.
fn top_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get(ui.ctx());
ui.horizontal_top(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(LABEL, 24.0), egui::Sense::hover());
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
add(ui);
});
}
/// Five stars: a click sets the rating, a click on the current one clears it.
fn rating(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let current = d.f64("rating", 0.0) as u8;
ui.spacing_mut().item_spacing.x = 2.0;
for i in 1..=MAX_RATING {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::click());
let on = i <= current;
let tint = if on {
t.accent
} else if resp.hovered() {
t.text
} else {
t.text_dim
};
icons::paint(ui, "star", rect.shrink(2.0), tint);
let resp = resp.on_hover_text(if i == 1 { "1 star".to_string() } else { format!("{i} stars") });
if resp.clicked() {
d.fields.insert("rating".into(), json!(if i == current { 0 } else { i }));
}
}
}
/// The keywords as chips; a chip's × removes it.
fn chips(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let list = keywords(d);
if list.is_empty() {
return;
}
let mut remove = None;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
for (i, k) in list.iter().enumerate() {
let galley = ui.painter().layout_no_wrap(k.clone(), egui::FontId::proportional(12.0), t.text_strong);
let size = egui::vec2(galley.size().x + 30.0, 22.0);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
ui.painter().rect_filled(rect, egui::CornerRadius::same(11), t.hover);
ui.painter().rect_stroke(rect, egui::CornerRadius::same(11), egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
ui.painter().galley(egui::pos2(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, t.text_strong);
let x = egui::Rect::from_center_size(egui::pos2(rect.right() - 11.0, rect.center().y), egui::vec2(14.0, 14.0));
let resp = ui.interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click()).on_hover_text(format!("Remove “{k}”"));
icons::paint(ui, "x", x.shrink(2.0), if resp.hovered() { t.text_strong } else { t.text_dim });
if resp.clicked() {
remove = Some(i);
}
}
});
if let Some(i) = remove {
let mut list = list;
list.remove(i);
d.fields.insert("keywords".into(), json!(list));
}
ui.add_space(4.0);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, Default::default());
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn file_info_draws_and_ok_is_one_undo_step() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
// The menu item and its shortcut open the dialog; agents use ui.fileInfoDialog.
crate::menus::invoke(&mut app, "file.info", json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::KIND));
let d = app.ui.dialog.as_mut().unwrap();
assert!(d.fields["created"].is_string(), "a new document has its creation date");
for (k, v) in [("author", json!("Ada")), ("keywords", json!(["poster"])), ("rating", json!(4)), ("copyrightStatus", json!("copyrighted"))] {
d.fields.insert(k.into(), v);
}
d.fields.insert(super::PENDING.into(), json!("print, Poster; blue"));
frame(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let st = app.session.active().unwrap();
let m = &st.doc.metadata;
assert_eq!((m.author.as_str(), m.rating, m.copyright_status), ("Ada", 4, vectorcraft_doc::CopyrightStatus::Copyrighted));
assert_eq!(m.keywords, ["poster", "print", "blue"], "typed keywords join the chips, each once");
assert_eq!(st.history.undo.len(), 1);
// A bad value keeps the dialog open.
app.run("ui.fileInfoDialog", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("rating".into(), json!(9));
assert!(crate::dialogs::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
}

View File

@ -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);

View File

@ -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).

View File

@ -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<Value, String> {
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<Value, String> {
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(_)))));
}
}

View File

@ -109,6 +109,7 @@ pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool, param
let existing = if save_as { None } else { st.path.clone() };
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
let f = fileio::save_format(format_param(params), Some(&path))?;
fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?);
let st = app.session.active().ok_or("no document")?;
let (enc, opts) = fileio::save_encoding(st, f, params).map_err(|e| e.to_string())?;
let doc = st.doc.clone();

View File

@ -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<R
Ok(Value::Null)
}
"file.exportAs" => 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<Item>)> {
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()),
}

View File

@ -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.

View File

@ -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 `<desc>` and, with the `metadata` option,
all of it as Dublin Core.
```json
{"name":"run_command","arguments":{"command":"file.info","params":{"author":"Ada","keywords":"poster, fair","rating":4,"copyrightStatus":"copyrighted","copyrightNotice":"© 2026 Ada"}}}
```
## 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}}}
```