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