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:
commit
0c65fe77f3
|
|
@ -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))));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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() });
|
||||
}
|
||||
}
|
||||
|
|
@ -158,9 +158,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"File Info…",
|
||||
["File"],
|
||||
Some("Cmd+Alt+Shift+I"),
|
||||
"{title?} set the document title; always → {title, colorMode, units, artboards, objects}",
|
||||
"{title?, author?, authorTitle?, description?, keywords?: [string…]|\"a, b\" (each once), rating?: 0–5, copyrightStatus?: \"unknown\"|\"copyrighted\"|\"publicDomain\", copyrightNotice?, copyrightUrl?} set the File Info in one undo step (SVG with metadata, PDF and PNG exports carry it); no params → {title, author, authorTitle, description, keywords, rating, copyrightStatus, copyrightNotice, copyrightUrl, created, modified (ISO 8601 UTC or null; read-only), colorMode, units, artboards, objects}",
|
||||
has_doc,
|
||||
file_info
|
||||
super::fileinfo::file_info
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -484,21 +484,3 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(t) = str_param(p, "title") {
|
||||
let t = t.to_string();
|
||||
s.edit("File Info", |d, _| {
|
||||
d.title = t;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(json!({
|
||||
"title": d.title,
|
||||
"colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" },
|
||||
"units": d.units.label(),
|
||||
"artboards": d.artboards.len(),
|
||||
"objects": d.node_count(),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
|
||||
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
|
||||
//! SVG (with `metadata`), PDF and PNG writers carry it.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
|
||||
use vectorcraft_doc::{ColorMode, CopyrightStatus, DocMetadata, Document};
|
||||
|
||||
use super::*;
|
||||
|
||||
const C: &str = "file.info";
|
||||
/// Longest text field, in characters.
|
||||
const MAX_TEXT: usize = 65_536;
|
||||
/// Longest keyword, in characters.
|
||||
const MAX_KEYWORD: usize = 256;
|
||||
const MAX_KEYWORDS: usize = 1000;
|
||||
/// Values `file.info` reports but doesn't take (fed back, they are ignored).
|
||||
const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artboards", "objects"];
|
||||
|
||||
/// What `file.info` reports (and takes back).
|
||||
pub(crate) fn info_json(d: &Document) -> Value {
|
||||
let m = &d.metadata;
|
||||
json!({
|
||||
"title": d.title,
|
||||
"author": m.author,
|
||||
"authorTitle": m.author_title,
|
||||
"description": m.description,
|
||||
"keywords": m.keywords,
|
||||
"rating": m.rating,
|
||||
"copyrightStatus": m.copyright_status.id(),
|
||||
"copyrightNotice": m.copyright_notice,
|
||||
"copyrightUrl": m.copyright_url,
|
||||
"created": m.created.map(iso8601),
|
||||
"modified": m.modified.map(iso8601),
|
||||
"colorMode": match d.color_mode { ColorMode::Rgb => "rgb", ColorMode::Cmyk => "cmyk" },
|
||||
"units": d.units.label(),
|
||||
"artboards": d.artboards.len(),
|
||||
"objects": d.node_count(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let empty = Map::new();
|
||||
let o = match p {
|
||||
Value::Null => &empty,
|
||||
Value::Object(o) => o,
|
||||
_ => return Err(bad(C, "params must be an object")),
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut title, mut m) = (d.title.clone(), d.metadata.clone());
|
||||
for (k, v) in o {
|
||||
apply(&mut title, &mut m, k, v)?;
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
if title != d.title || m != d.metadata {
|
||||
s.edit("File Info", |d, _| {
|
||||
d.title = title;
|
||||
d.metadata = m;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(info_json(&s.doc()?.doc))
|
||||
}
|
||||
|
||||
fn apply(title: &mut String, m: &mut DocMetadata, k: &str, v: &Value) -> Result<()> {
|
||||
match k {
|
||||
"title" => {
|
||||
*title = text(k, v, false)?;
|
||||
if title.is_empty() {
|
||||
return Err(bad(C, "title can't be empty"));
|
||||
}
|
||||
}
|
||||
"author" => m.author = text(k, v, false)?,
|
||||
"authorTitle" => m.author_title = text(k, v, false)?,
|
||||
"description" => m.description = text(k, v, true)?,
|
||||
"keywords" => m.keywords = keywords(v)?,
|
||||
"rating" => {
|
||||
m.rating = v
|
||||
.as_u64()
|
||||
.filter(|r| *r <= u64::from(MAX_RATING))
|
||||
.and_then(|r| u8::try_from(r).ok())
|
||||
.ok_or_else(|| bad(C, format!("rating must be a whole number from 0 to {MAX_RATING}")))?
|
||||
}
|
||||
"copyrightStatus" => {
|
||||
m.copyright_status =
|
||||
v.as_str().and_then(CopyrightStatus::parse).ok_or_else(|| bad(C, "copyrightStatus must be unknown, copyrighted or publicDomain"))?
|
||||
}
|
||||
"copyrightNotice" => m.copyright_notice = text(k, v, true)?,
|
||||
"copyrightUrl" => {
|
||||
let url = text(k, v, false)?;
|
||||
if url.chars().any(char::is_whitespace) || url.chars().count() > 2048 {
|
||||
return Err(bad(C, "copyrightUrl must be a URL (no spaces, at most 2048 characters)"));
|
||||
}
|
||||
m.copyright_url = url;
|
||||
}
|
||||
k if READ_ONLY.contains(&k) => {}
|
||||
_ => return Err(bad(C, format!("unknown File Info field `{k}`"))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A trimmed text value (`null` clears it); line breaks only where `multiline`, no other control
|
||||
/// characters.
|
||||
fn text(k: &str, v: &Value, multiline: bool) -> Result<String> {
|
||||
let s = match v {
|
||||
Value::Null => "",
|
||||
Value::String(s) => s.trim(),
|
||||
_ => return Err(bad(C, format!("{k} must be text"))),
|
||||
};
|
||||
if s.chars().count() > MAX_TEXT {
|
||||
return Err(bad(C, format!("{k} is too long (at most {MAX_TEXT} characters)")));
|
||||
}
|
||||
if s.chars().any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t'))) {
|
||||
return Err(bad(C, format!("{k} has control characters{}", if multiline { "" } else { " or line breaks" })));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
|
||||
/// case), empty ones dropped.
|
||||
fn keywords(v: &Value) -> Result<Vec<String>> {
|
||||
let parts: Vec<String> = match v {
|
||||
Value::Null => vec![],
|
||||
Value::String(s) => s.split([',', ';']).map(str::to_string).collect(),
|
||||
Value::Array(a) => a.iter().map(|x| text("keywords", x, false)).collect::<Result<_>>()?,
|
||||
_ => return Err(bad(C, "keywords must be a list of words or a comma-separated string")),
|
||||
};
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out: Vec<String> = vec![];
|
||||
for k in parts.iter().map(|k| k.trim()).filter(|k| !k.is_empty()) {
|
||||
if k.chars().count() > MAX_KEYWORD || k.chars().any(char::is_control) {
|
||||
return Err(bad(C, format!("keywords must be at most {MAX_KEYWORD} characters, without control characters")));
|
||||
}
|
||||
if seen.insert(k.to_lowercase()) {
|
||||
out.push(k.to_string());
|
||||
}
|
||||
if out.len() > MAX_KEYWORDS {
|
||||
return Err(bad(C, format!("at most {MAX_KEYWORDS} keywords")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -272,7 +272,14 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
|
||||
check_format_size(f, region.width() * scale, region.height() * scale)?;
|
||||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||||
enc.files.push((Some(b), renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?));
|
||||
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
|
||||
// File Info as PNG text chunks.
|
||||
let bytes = if format == RasterFormat::Png {
|
||||
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
enc.files.push((Some(b), bytes));
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ mod export;
|
|||
mod load;
|
||||
pub mod pdf;
|
||||
mod pdfimport;
|
||||
pub mod pngtext;
|
||||
pub mod ppi;
|
||||
mod save;
|
||||
mod svg;
|
||||
|
|
@ -30,7 +31,7 @@ use load::err;
|
|||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image};
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub use save::{save_encoding, save_format};
|
||||
pub use save::{save_encoding, save_format, stamp_save_dates};
|
||||
pub use svg::options_map as svg_options;
|
||||
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
//! PNG text chunks (`tEXt`, uncompressed `iTXt`) read back; the PNG writer adds them
|
||||
//! ([`vectorcraft_render::encode::png::with_text`]).
|
||||
|
||||
/// The `tEXt` and uncompressed `iTXt` entries of a PNG before its image data: (keyword, text).
|
||||
pub fn text(png: &[u8]) -> Vec<(String, String)> {
|
||||
if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return vec![];
|
||||
}
|
||||
super::ppi::png_chunks(png)
|
||||
.take_while(|(ty, _)| *ty != b"IDAT")
|
||||
.filter_map(|(ty, data)| {
|
||||
let nul = data.iter().position(|b| *b == 0)?;
|
||||
let keyword: String = data.get(..nul)?.iter().map(|b| char::from(*b)).collect();
|
||||
let rest = data.get(nul + 1..)?;
|
||||
let text = match ty {
|
||||
b"tEXt" => rest.iter().map(|b| char::from(*b)).collect(),
|
||||
// Compression flag 0, method, then the language tag and translated keyword.
|
||||
b"iTXt" if rest.first() == Some(&0) => {
|
||||
let mut parts = rest.get(2..)?.splitn(3, |b| *b == 0);
|
||||
let (_, _, text) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
String::from_utf8_lossy(text).into_owned()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some((keyword, text))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_render::encode::png::with_text;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_chunks_round_trip_and_stay_valid() {
|
||||
let png = vectorcraft_render::Rendered { width: 2, height: 1, pixels: vec![255; 8] }.to_png().unwrap();
|
||||
let out = with_text(png.clone(), &[("Title", "Café".into()), ("Author", "Łukasz".into()), ("bad\u{1}", "x".into())]);
|
||||
assert_eq!(text(&out), [("Title".to_string(), "Café".to_string()), ("Author".into(), "Łukasz".into())]);
|
||||
// Still a PNG every decoder reads (the CRCs check out).
|
||||
let img = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (2, 1));
|
||||
assert_eq!(with_text(b"not a png".to_vec(), &[("Title", "x".into())]), b"not a png");
|
||||
assert_eq!(with_text(png.clone(), &[]), png);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ fn per_metre(ppm: u32) -> f64 {
|
|||
}
|
||||
|
||||
/// PNG chunks after the signature: (type, data).
|
||||
fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
|
||||
pub(super) fn png_chunks(b: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> {
|
||||
let mut at = 8;
|
||||
std::iter::from_fn(move || {
|
||||
let len = be32(b, at)? as usize;
|
||||
|
|
|
|||
|
|
@ -39,10 +39,22 @@ pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, V
|
|||
Ok((enc, opts))
|
||||
}
|
||||
|
||||
/// File Info's dates for a save to a file: the modified date (and the created date, when it has
|
||||
/// none) become now. Not an undo step; the web build, without a clock, leaves them.
|
||||
pub fn stamp_save_dates(st: &mut DocState) {
|
||||
let Some(now) = vectorcraft_doc::metadata::now_unix() else { return };
|
||||
let d = std::sync::Arc::make_mut(&mut st.doc);
|
||||
d.metadata.created.get_or_insert(now);
|
||||
d.metadata.modified = Some(now);
|
||||
}
|
||||
|
||||
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.save";
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
|
||||
let f = save_format(str_param(p, "format"), path.as_deref()).map_err(|e| bad(C, e))?;
|
||||
if path.is_some() {
|
||||
stamp_save_dates(s.doc_mut()?);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let (enc, opts) = save_encoding(st, f, p)?;
|
||||
let Some(path) = path else {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,12 @@ pub const OPTIONS: &[FormatOption] = &[
|
|||
default: "false",
|
||||
description: "embed the native document in <metadata> so VectorCraft reopens the SVG with nothing lost",
|
||||
},
|
||||
FormatOption { name: "metadata", ty: "boolean", default: "false", description: "write <metadata> with the title and format (Dublin Core)" },
|
||||
FormatOption {
|
||||
name: "metadata",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "write <metadata> with the title, format and File Info (Dublin Core)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "fewerTspans",
|
||||
ty: "boolean",
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ mod draw2;
|
|||
mod edit;
|
||||
mod effectcmd;
|
||||
pub mod expand;
|
||||
pub(crate) mod fileinfo;
|
||||
pub mod fileio;
|
||||
pub mod flatten;
|
||||
mod fonts;
|
||||
|
|
|
|||
|
|
@ -558,6 +558,7 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(t) => t.to_string(),
|
||||
None => s.next_untitled(),
|
||||
};
|
||||
d.metadata.created = vectorcraft_doc::metadata::now_unix();
|
||||
let i = s.add_document(d, None);
|
||||
// Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both
|
||||
// are views of the whole app, so Default leaves them as they are.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>"));
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
@ -69,7 +69,19 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
|||
if !title.is_empty() {
|
||||
meta = meta.title(title);
|
||||
}
|
||||
if let Some(t) = opts.created.or_else(now_unix) {
|
||||
// File Info.
|
||||
let info = &doc.metadata;
|
||||
let given = |s: &str| Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
if let Some(a) = given(&info.author) {
|
||||
meta = meta.authors(vec![a]);
|
||||
}
|
||||
if let Some(d) = given(&info.description) {
|
||||
meta = meta.description(d);
|
||||
}
|
||||
if !info.keywords.is_empty() {
|
||||
meta = meta.keywords(info.keywords.clone());
|
||||
}
|
||||
if let Some(t) = opts.created.or_else(vectorcraft_doc::metadata::now_unix) {
|
||||
meta = meta.creation_date(date_time(t));
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
|
|
@ -175,35 +187,15 @@ fn number(digits: &[u8]) -> Option<u32> {
|
|||
std::str::from_utf8(digits).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn now_unix() -> Option<i64> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn now_unix() -> Option<i64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Unix seconds (UTC) → krilla date (civil-from-days, proleptic Gregorian).
|
||||
/// Unix seconds (UTC) → krilla date.
|
||||
fn date_time(t: i64) -> krilla::metadata::DateTime {
|
||||
let days = t.div_euclid(86_400);
|
||||
let secs = t.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
krilla::metadata::DateTime::new(year.clamp(0, 9999) as u16)
|
||||
let [year, month, day, hour, minute, second] = vectorcraft_doc::metadata::civil(t);
|
||||
krilla::metadata::DateTime::new(year as u16)
|
||||
.month(month as u8)
|
||||
.day(day as u8)
|
||||
.hour((secs / 3600) as u8)
|
||||
.minute((secs / 60 % 60) as u8)
|
||||
.second((secs % 60) as u8)
|
||||
.hour(hour as u8)
|
||||
.minute(minute as u8)
|
||||
.second(second as u8)
|
||||
.utc_offset_hour(0)
|
||||
.utc_offset_minute(0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,44 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Ve
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// `png` (a PNG file) with a text chunk per `(keyword, text)` right after its header: `tEXt`, or
|
||||
/// uncompressed `iTXt` (UTF-8) for text beyond Latin-1. Keywords are 1–79 printable ASCII
|
||||
/// characters (others are skipped). Unchanged when it doesn't start with a PNG header.
|
||||
pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
|
||||
// The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first.
|
||||
const HEADER: usize = 8 + 12 + 13;
|
||||
if entries.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER {
|
||||
return png;
|
||||
}
|
||||
let mut out = Vec::with_capacity(png.len() + entries.iter().map(|(k, v)| k.len() + v.len() + 20).sum::<usize>());
|
||||
out.extend_from_slice(&png[..HEADER]);
|
||||
for (keyword, text) in entries {
|
||||
if keyword.is_empty() || keyword.len() > 79 || !keyword.bytes().all(|b| (32..=126).contains(&b)) {
|
||||
continue;
|
||||
}
|
||||
let mut data = keyword.as_bytes().to_vec();
|
||||
data.push(0);
|
||||
let latin1: Option<Vec<u8>> = text.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect();
|
||||
let ty = match latin1 {
|
||||
Some(bytes) => {
|
||||
data.extend(bytes);
|
||||
b"tEXt"
|
||||
}
|
||||
None => {
|
||||
// Uncompressed, no language tag, no translated keyword.
|
||||
data.extend_from_slice(&[0, 0, 0, 0]);
|
||||
data.extend_from_slice(text.as_bytes());
|
||||
b"iTXt"
|
||||
}
|
||||
};
|
||||
if u32::try_from(data.len()).is_ok() {
|
||||
chunk(&mut out, ty, &data);
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&png[HEADER..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Append one chunk: length, type, data, CRC of type and data.
|
||||
fn chunk(out: &mut Vec<u8>, ty: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
|
|
|
|||
|
|
@ -112,6 +112,11 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
|||
out.push_str(&w.indent(1));
|
||||
out.push_str(&format!("<title>{}</title>{nl}", xml_escape(&doc.title)));
|
||||
}
|
||||
// File Info's description.
|
||||
if !doc.metadata.description.trim().is_empty() {
|
||||
out.push_str(&w.indent(1));
|
||||
out.push_str(&format!("<desc>{}</desc>{nl}", xml_escape(&doc.metadata.description)));
|
||||
}
|
||||
// The editing data carries a hash of the markup around its `<metadata>`.
|
||||
let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "</svg>", nl])));
|
||||
w.metadata(&mut out, native);
|
||||
|
|
@ -301,12 +306,17 @@ fn pin_to_page(n: &mut Node, m: Affine) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Dublin Core terms File Info writes to `<metadata>`.
|
||||
/// The Dublin Core terms File Info writes to `<metadata>`: the format, title, author (creator),
|
||||
/// description, each keyword (subject), copyright notice and URL (rights) and created date, the
|
||||
/// empty ones left out.
|
||||
fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> {
|
||||
let info = &doc.metadata;
|
||||
let mut terms = vec![("format", "image/svg+xml".to_string())];
|
||||
if !doc.title.is_empty() {
|
||||
terms.push(("title", doc.title.clone()));
|
||||
}
|
||||
let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default();
|
||||
let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
|
||||
let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())];
|
||||
let keywords = info.keywords.iter().map(|k| ("subject", k.as_str()));
|
||||
terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string())));
|
||||
terms
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ pub struct ExportOptions {
|
|||
/// Embed the native document (passed to [`export_full`]) in `<metadata>` so VectorCraft
|
||||
/// reopens the SVG with nothing lost (see [`editing`]).
|
||||
pub preserve_editing: bool,
|
||||
/// Write `<metadata>` with the document's Dublin Core title and format.
|
||||
/// Write `<metadata>` with the document's title, format and File Info (Dublin Core).
|
||||
pub metadata: bool,
|
||||
/// One positioned `<tspan>` per line of type instead of one per style run, tab stop and
|
||||
/// justified word (smaller; viewers then space the line with their own font metrics).
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ pub(super) const KIND: &str = "documentSetup";
|
|||
const TABS: [&str; 3] = ["General", "Transparency", "Type"];
|
||||
/// Dialog-only fields `document.setup` doesn't take.
|
||||
const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"];
|
||||
const LABEL: f32 = 130.0;
|
||||
pub(super) const LABEL: f32 = 130.0;
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Document Setup".into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
|
||||
|
|
@ -60,7 +60,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
|
||||
/// A dropdown row bound to the string `d.fields[key]`; `options` are (value, label).
|
||||
fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) {
|
||||
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) {
|
||||
let cur = d.str(key);
|
||||
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
|
||||
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
|
||||
|
|
@ -72,7 +72,7 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[
|
|||
}
|
||||
|
||||
/// A checkbox bound to `d.fields[key]`.
|
||||
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
let on = d.bool(key);
|
||||
if widgets::check(ui, label, on, true) {
|
||||
d.fields.insert(key.into(), json!(!on));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,234 @@
|
|||
//! File → File Info: the document title, author, description, rating, keywords (chips) and
|
||||
//! copyright, with the created and modified dates.
|
||||
//!
|
||||
//! Fields: what `file.info` reports (and takes back) plus `__keyword`, the keyword being typed
|
||||
//! (Enter or Add turns it into chips; OK keeps it too). OK runs `file.info` with them: one undo
|
||||
//! step. On an error the dialog stays open.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::CopyrightStatus;
|
||||
use vectorcraft_doc::metadata::MAX_RATING;
|
||||
|
||||
use super::document_setup::{LABEL, choice};
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "fileInfo";
|
||||
/// The keyword being typed.
|
||||
const PENDING: &str = "__keyword";
|
||||
/// Width of the text fields.
|
||||
const FIELD: f32 = 320.0;
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "File Info".into(), body, confirm, min_width: 500.0, max_width: Some(500.0), ..DialogSpec::FORM };
|
||||
|
||||
/// Open File Info on the active document's.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let mut fields = app.session.execute("file.info", &json!({})).map_err(|e| e.to_string())?;
|
||||
fields[PENDING] = json!("");
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut d = d.clone();
|
||||
// A keyword typed but not added yet is kept.
|
||||
add_keywords(&mut d);
|
||||
let r = app.run("file.info", form::params(&d));
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The keywords in `d`.
|
||||
fn keywords(d: &Dialog) -> Vec<String> {
|
||||
d.fields.get("keywords").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Move the typed keywords (comma- or semicolon-separated) into the list, each once.
|
||||
fn add_keywords(d: &mut Dialog) {
|
||||
let mut list = keywords(d);
|
||||
for k in d.str(PENDING).split([',', ';']).map(str::trim).filter(|k| !k.is_empty()) {
|
||||
if !list.iter().any(|o| o.to_lowercase() == k.to_lowercase()) {
|
||||
list.push(k.to_string());
|
||||
}
|
||||
}
|
||||
d.fields.insert("keywords".into(), json!(list));
|
||||
d.fields.insert(PENDING.into(), json!(""));
|
||||
}
|
||||
|
||||
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
for (key, label) in [("title", "Document Title:"), ("author", "Author:"), ("authorTitle", "Author Title:")] {
|
||||
widgets::label_row(ui, label, LABEL, |ui| {
|
||||
form::text(ui, d, key, FIELD);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
top_row(ui, "Description:", |ui| {
|
||||
form::text_area(ui, d, "description", FIELD, 3);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::label_row(ui, "Rating:", LABEL, |ui| rating(ui, d));
|
||||
ui.add_space(4.0);
|
||||
top_row(ui, "Keywords:", |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(FIELD + 12.0);
|
||||
chips(ui, d);
|
||||
ui.horizontal(|ui| {
|
||||
let r = form::text_edit(ui, d, PENDING, FIELD - 70.0);
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if enter {
|
||||
// Enter adds the keyword (it doesn't press OK) and keeps typing in the field.
|
||||
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
r.request_focus();
|
||||
}
|
||||
let typed = !d.str(PENDING).trim().is_empty();
|
||||
if (widgets::flat_button(ui, "Add", 56.0).clicked() || enter) && typed {
|
||||
add_keywords(d);
|
||||
}
|
||||
});
|
||||
form::caption(ui, "Separate several keywords with commas.");
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Copyright");
|
||||
let statuses: Vec<(&str, &str)> = CopyrightStatus::ALL.iter().map(|c| (c.id(), c.label())).collect();
|
||||
choice(ui, d, "copyrightStatus", "Copyright Status:", &statuses);
|
||||
ui.add_space(4.0);
|
||||
top_row(ui, "Copyright Notice:", |ui| {
|
||||
form::text_area(ui, d, "copyrightNotice", FIELD, 2);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::label_row(ui, "Copyright Info URL:", LABEL, |ui| {
|
||||
form::text(ui, d, "copyrightUrl", FIELD);
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Dates");
|
||||
for (key, label) in [("created", "Created:"), ("modified", "Modified:")] {
|
||||
// ISO 8601 UTC from the engine, shown as "2026-10-05 14:03:00 UTC".
|
||||
let shown = d
|
||||
.fields
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map_or_else(|| "—".to_string(), |s| format!("{} UTC", s.replace('T', " ").trim_end_matches('Z')));
|
||||
widgets::label_row(ui, label, LABEL, |ui| {
|
||||
ui.label(egui::RichText::new(shown).color(t.text_dim));
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A form row whose label sits at the top of a taller field.
|
||||
fn top_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal_top(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(LABEL, 24.0), egui::Sense::hover());
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Five stars: a click sets the rating, a click on the current one clears it.
|
||||
fn rating(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let current = d.f64("rating", 0.0) as u8;
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for i in 1..=MAX_RATING {
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::click());
|
||||
let on = i <= current;
|
||||
let tint = if on {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.text_dim
|
||||
};
|
||||
icons::paint(ui, "star", rect.shrink(2.0), tint);
|
||||
let resp = resp.on_hover_text(if i == 1 { "1 star".to_string() } else { format!("{i} stars") });
|
||||
if resp.clicked() {
|
||||
d.fields.insert("rating".into(), json!(if i == current { 0 } else { i }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The keywords as chips; a chip's × removes it.
|
||||
fn chips(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let list = keywords(d);
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut remove = None;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
|
||||
for (i, k) in list.iter().enumerate() {
|
||||
let galley = ui.painter().layout_no_wrap(k.clone(), egui::FontId::proportional(12.0), t.text_strong);
|
||||
let size = egui::vec2(galley.size().x + 30.0, 22.0);
|
||||
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::same(11), t.hover);
|
||||
ui.painter().rect_stroke(rect, egui::CornerRadius::same(11), egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
ui.painter().galley(egui::pos2(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, t.text_strong);
|
||||
let x = egui::Rect::from_center_size(egui::pos2(rect.right() - 11.0, rect.center().y), egui::vec2(14.0, 14.0));
|
||||
let resp = ui.interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click()).on_hover_text(format!("Remove “{k}”"));
|
||||
icons::paint(ui, "x", x.shrink(2.0), if resp.hovered() { t.text_strong } else { t.text_dim });
|
||||
if resp.clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(i) = remove {
|
||||
let mut list = list;
|
||||
list.remove(i);
|
||||
d.fields.insert("keywords".into(), json!(list));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_info_draws_and_ok_is_one_undo_step() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
// The menu item and its shortcut open the dialog; agents use ui.fileInfoDialog.
|
||||
crate::menus::invoke(&mut app, "file.info", json!({}));
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::KIND));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert!(d.fields["created"].is_string(), "a new document has its creation date");
|
||||
for (k, v) in [("author", json!("Ada")), ("keywords", json!(["poster"])), ("rating", json!(4)), ("copyrightStatus", json!("copyrighted"))] {
|
||||
d.fields.insert(k.into(), v);
|
||||
}
|
||||
d.fields.insert(super::PENDING.into(), json!("print, Poster; blue"));
|
||||
frame(&mut app);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let st = app.session.active().unwrap();
|
||||
let m = &st.doc.metadata;
|
||||
assert_eq!((m.author.as_str(), m.rating, m.copyright_status), ("Ada", 4, vectorcraft_doc::CopyrightStatus::Copyrighted));
|
||||
assert_eq!(m.keywords, ["poster", "print", "blue"], "typed keywords join the chips, each once");
|
||||
assert_eq!(st.history.undo.len(), 1);
|
||||
// A bad value keeps the dialog open.
|
||||
app.run("ui.fileInfoDialog", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("rating".into(), json!(9));
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,26 @@ pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32
|
|||
r.inner
|
||||
}
|
||||
|
||||
/// A text box `rows` lines tall and `width` wide bound to `d.fields[key]`. Enter adds a line (it
|
||||
/// doesn't press OK while the box has focus).
|
||||
pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, rows: usize) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut s = d.str(key);
|
||||
let r = egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(egui::Stroke::new(1.0, t.input_border))
|
||||
.corner_radius(egui::CornerRadius::same(3))
|
||||
.inner_margin(egui::Margin::symmetric(6, 3))
|
||||
.show(ui, |ui| ui.add(egui::TextEdit::multiline(&mut s).frame(egui::Frame::NONE).desired_width(width).desired_rows(rows)));
|
||||
if r.inner.changed() {
|
||||
d.fields.insert(key.into(), Value::String(s));
|
||||
}
|
||||
if r.inner.has_focus() {
|
||||
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
}
|
||||
r.inner
|
||||
}
|
||||
|
||||
/// A checkbox bound to `d.fields[key]`.
|
||||
pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
let mut b = d.bool(key);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub mod expand;
|
|||
mod export_as;
|
||||
mod export_for_screens;
|
||||
pub mod eyedropper;
|
||||
pub mod file_info;
|
||||
pub mod flatten;
|
||||
pub mod flattener_presets;
|
||||
mod form;
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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(_)))));
|
||||
}
|
||||
}
|
||||
|
|
@ -109,6 +109,7 @@ pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool, param
|
|||
let existing = if save_as { None } else { st.path.clone() };
|
||||
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
|
||||
let f = fileio::save_format(format_param(params), Some(&path))?;
|
||||
fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?);
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let (enc, opts) = fileio::save_encoding(st, f, params).map_err(|e| e.to_string())?;
|
||||
let doc = st.doc.clone();
|
||||
|
|
|
|||
|
|
@ -382,6 +382,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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
29
docs/mcp.md
29
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 `<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}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue