mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M4.56: Document Raster Effects Settings with colour model, background, anti-alias, clipping mask and room around
document.rasterEffectsSettings now also takes and reports colorModel, background, antiAlias, clippingMask, addAround and preserveSpotColors in one undo step (Document.raster_effects, RasterEffectsSettings, next to the existing raster_effects_ppi so old files load unchanged). The raster effect images of PDF export and Expand Appearance get the extra room and are finished by the settings (hard edges, grayscale or bitmap, opaque on white, white only under the art with a clipping mask). Rasterize takes them as defaults (params override), and its clipping mask puts the image in a clip group with the art's outline. The settings have their own dialog (rasterEffectsSettings, ui.rasterEffectsSettingsDialog) and Rasterize… opens its options prefilled from them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6a446b4fa3
commit
94f0f1b0d2
12 files changed
+673
-58
No files matched your search
@@ -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))));
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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) });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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(_)))));
|
||||
}
|
||||
}
|
||||
+22
-13
@@ -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<R
|
||||
}
|
||||
"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)
|
||||
@@ -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<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…",
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
+14
@@ -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}}}
|
||||
```
|
||||
Reference in new issue
Block a user