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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 09:55:48 +07:00
co-authored by Claude Opus 5.5
parent 6a446b4fa3
commit 94f0f1b0d2
12 changed files with 673 additions and 58 deletions
+1 -6
View File
@@ -1041,12 +1041,7 @@ fn raster_node(d: &mut Document, r: Raster) -> Node {
d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(r.png) });
let image = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None }));
let Some(path) = r.clip else { return image };
let mut clip = shape_node(d, path, None);
clip.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
super::rasterfx::clip_group(d, path, FillRule::NonZero, image)
}
/// Replace the type under `root` by its outlines: → the id now standing for `root`.
+53 -14
View File
@@ -7,8 +7,9 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::marks::{MarkStyle, TrimMarks};
use vectorcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2};
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
use super::edit::{duplicate_in, selected_roots};
@@ -90,7 +91,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?: (document raster effects resolution), background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art (default)|type (text snapped to pixels), padding?: pt} replace the selection with an embedded PNG image → {id, width, height}",
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height}",
has_selection,
rasterize
),
@@ -318,12 +319,20 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
}
}
fn render_png(doc: &vectorcraft_doc::Document, region: Rect, scale: f64, opts: &vectorcraft_render::RenderOptions) -> Result<(Vec<u8>, u32, u32)> {
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`.
fn render_png(
doc: &vectorcraft_doc::Document,
region: Rect,
scale: f64,
opts: &vectorcraft_render::RenderOptions,
color: RasterColorModel,
) -> Result<(Vec<u8>, u32, u32)> {
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
}
let mut r = vectorcraft_render::Renderer::new();
let img = r.render_region_with(doc, region, scale, opts);
let mut img = r.render_region_with(doc, region, scale, opts);
RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels);
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
}
@@ -335,13 +344,33 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
if !(1.0..=2400.0).contains(&ppi) {
return Err(bad(C, "ppi must be between 1 and 2400"));
}
let pad = f64_or(p, "padding", 0.0).max(0.0);
let raster = RasterExportOptions {
ppi,
background: fileio::background(p.get("background").unwrap_or(&Value::Null)).map_err(|e| bad(C, e))?,
anti_alias: str_param(p, "antiAlias").map(fileio::anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(),
..Default::default()
};
// The document's raster effects settings, changed by the params.
let mut look = st.doc.raster_effects.clone();
let mut background = (look.background == vectorcraft_doc::Background::White).then_some([255; 3]);
let mut anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
for (k, v) in p.as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) {
match k.as_str() {
"background" => background = fileio::background(v).map_err(|e| bad(C, e))?,
"antiAlias" => {
anti_alias = match v {
Value::Bool(b) => {
if *b {
AntiAlias::Art
} else {
AntiAlias::None
}
}
v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(C, e))?,
}
}
"padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, C, k)?,
"colorModel" => look.color_model = super::rasterfx::color_model(v, st.doc.color_mode, C)?,
"clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(C, "clippingMask must be true or false"))?,
_ => {}
}
}
let raster = RasterExportOptions { ppi, background, anti_alias, ..Default::default() };
let pad = look.add_around;
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad);
let scale = ppi / 72.0;
// Snap to whole pixels.
@@ -354,8 +383,14 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
n
})
.collect();
// Create Clipping Mask: the image is clipped to the art's outline.
let clip = look
.clipping_mask
.then(|| vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())))
.flatten()
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
let tmp = isolated_doc(&st.doc, nodes);
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png))?;
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
let id = s.edit("Rasterize", |d, sel| {
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
@@ -363,7 +398,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
let id = d.alloc_id();
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
if let Some((path, rule)) = clip {
node = super::rasterfx::clip_group(d, path, rule, node);
}
let id = node.id;
d.insert(par, idx + 1, node)?;
for r in &roots {
d.remove(*r)?;
@@ -505,7 +544,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
bare.visible = true;
let tmp = isolated_doc(&st.doc, vec![bare]);
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png))?;
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
s.edit("Crop Image", |d, _| {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
+118 -25
View File
@@ -1,17 +1,21 @@
//! Effect → Document Raster Effects Settings, and raster effects for PDF export.
//!
//! PDF has no live shadows, glows or blurs, so PDF export receives a copy of the document in which
//! every object with a raster effect is accompanied by an image rendered at the document's raster
//! effects resolution (like Illustrator). Shadows and outer glows only add the effect: the image
//! (with the object's own area knocked out) goes under the untouched vector object. Effects that
//! change the object itself (inner glow, feather, Gaussian blur) replace it with the image.
//! every object with a raster effect is accompanied by an image rendered with the document's raster
//! effects settings (resolution, colour model, background, anti-aliasing, room around the art).
//! Shadows and outer glows only add the effect: the image (with the object's own area knocked out)
//! goes under the untouched vector object. Effects that change the object itself (inner glow,
//! feather, Gaussian blur) replace it with the image.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use serde_json::{Map, Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect};
use vectorcraft_render::effects::{self, RasterFx};
use vectorcraft_render::{AntiAlias, RenderOptions};
use super::*;
@@ -21,32 +25,114 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Raster Effects Settings…",
["Effect"],
None,
"{resolution?: ppi (1–2400) | \"screen\" (72) | \"medium\" (150) | \"high\" (300)} resolution of raster effects in PDF export and Rasterize; no params → {resolution}",
"{resolution?: ppi (1–2400) | \"screen\" (72) | \"medium\" (150) | \"high\" (300), colorModel?: \"rgb\"|\"cmyk\" (the document's mode)|\"grayscale\"|\"bitmap\", background?: \"transparent\"|\"white\", antiAlias?: bool (off: hard edges), clippingMask?: bool (white background only under the art; Rasterize clips to the art), addAround?: pt (0–1000, room around the art), preserveSpotColors?: bool (stored)} how raster effects (shadows, glows, blurs, feathers) become images in PDF export and Expand Appearance, and the defaults of object.rasterize; one undo step; always → the settings",
has_doc,
settings
)]
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.rasterEffectsSettings";
let ppi = match p.get("resolution") {
None | Some(Value::Null) => return Ok(json!({ "resolution": s.doc()?.doc.raster_effects_ppi })),
Some(Value::String(v)) => match v.to_ascii_lowercase().as_str() {
const C: &str = "document.rasterEffectsSettings";
/// What `document.rasterEffectsSettings` reports (and takes back).
fn settings_json(d: &Document) -> Value {
let r = &d.raster_effects;
let model = match (r.color_model, d.color_mode) {
(RasterColorModel::Document, ColorMode::Rgb) => "rgb",
(RasterColorModel::Document, ColorMode::Cmyk) => "cmyk",
(m, _) => m.id(),
};
json!({
"resolution": d.raster_effects_ppi,
"colorModel": model,
"background": r.background.id(),
"antiAlias": r.anti_alias,
"clippingMask": r.clipping_mask,
"addAround": r.add_around,
"preserveSpotColors": r.preserve_spot_colors,
})
}
fn resolution(v: &Value) -> Result<f64> {
let ppi = match v {
Value::String(v) => match v.to_ascii_lowercase().as_str() {
"screen" => 72.0,
"medium" => 150.0,
"high" => 300.0,
other => other.trim_end_matches("ppi").trim().parse::<f64>().map_err(|_| bad(C, format!("unknown resolution `{v}`")))?,
},
Some(v) => v.as_f64().ok_or_else(|| bad(C, "resolution must be a number or screen|medium|high"))?,
v => v.as_f64().ok_or_else(|| bad(C, "resolution must be a number or screen|medium|high"))?,
};
if !(1.0..=2400.0).contains(&ppi) {
return Err(bad(C, "resolution must be between 1 and 2400 ppi"));
}
s.edit("Document Raster Effects Settings", |d, _| {
d.raster_effects_ppi = ppi;
Ok(())
})?;
Ok(json!({ "resolution": ppi }))
Ok(ppi)
}
/// `colorModel` for a document in `mode`: its own mode's name means the document's model.
pub(crate) fn color_model(v: &Value, mode: ColorMode, cmd: &str) -> Result<RasterColorModel> {
let s = v.as_str().unwrap_or_default().to_ascii_lowercase();
match (s.as_str(), mode) {
("document", _) | ("rgb", ColorMode::Rgb) | ("cmyk", ColorMode::Cmyk) => Ok(RasterColorModel::Document),
("grayscale" | "gray", _) => Ok(RasterColorModel::Grayscale),
("bitmap", _) => Ok(RasterColorModel::Bitmap),
_ => {
let own = if mode == ColorMode::Rgb { "rgb" } else { "cmyk" };
Err(bad(cmd, format!("colorModel must be {own} (the document's mode), grayscale or bitmap")))
}
}
}
/// `addAround`/`padding` in points.
pub(crate) fn add_around(v: &Value, cmd: &str, key: &str) -> Result<f64> {
v.as_f64().filter(|x| (0.0..=MAX_ADD_AROUND).contains(x)).ok_or_else(|| bad(cmd, format!("{key} must be 0–{MAX_ADD_AROUND} pt")))
}
fn flag(k: &str, v: &Value) -> Result<bool> {
v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false")))
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let empty = Map::new();
let o = match p {
Value::Null => &empty,
Value::Object(o) => o,
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
let (mut ppi, mut r) = (d.raster_effects_ppi, d.raster_effects.clone());
// A null value leaves its setting as it is.
for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) {
match k.as_str() {
"resolution" => ppi = resolution(v)?,
"colorModel" => r.color_model = color_model(v, d.color_mode, C)?,
"background" => r.background = super::docsetup::background_param(v, C)?,
"antiAlias" => r.anti_alias = flag(k, v)?,
"clippingMask" => r.clipping_mask = flag(k, v)?,
"addAround" => r.add_around = add_around(v, C, k)?,
"preserveSpotColors" => r.preserve_spot_colors = flag(k, v)?,
_ => return Err(bad(C, format!("unknown setting `{k}`"))),
}
}
if ppi != d.raster_effects_ppi || r != d.raster_effects {
s.edit("Document Raster Effects Settings", |d, _| {
d.raster_effects_ppi = ppi;
d.raster_effects = r;
Ok(())
})?;
}
Ok(settings_json(&s.doc()?.doc))
}
/// `art` in a clip group clipped by `path` filled by `rule` (a clipping path that paints nothing).
pub(crate) fn clip_group(d: &mut Document, path: PathData, rule: FillRule, art: Node) -> Node {
let mut clip = super::pathops::shape_node(d, path, None);
clip.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
match &mut clip.kind {
NodeKind::Path { clipping, rule: r, .. } => (*clipping, *r) = (true, rule),
NodeKind::Compound { rule: r, .. } => *r = rule,
_ => {}
}
Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true })
}
fn raster_fx(n: &Node) -> Vec<RasterFx> {
@@ -61,7 +147,7 @@ fn needs(n: &Node) -> bool {
}
/// Render `nodes` alone over transparency: (premultiplied pixels, width, height).
fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64) -> vectorcraft_render::Rendered {
fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64, anti_alias: AntiAlias) -> vectorcraft_render::Rendered {
let mut tmp = doc.clone();
let mut layer = Node::layer(NodeId(u64::MAX), "raster", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
@@ -70,7 +156,7 @@ fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64) -> vectorc
tmp.layers = vec![Arc::new(layer)];
let mut r = vectorcraft_render::Renderer::new();
r.threads = 0;
r.render_region(&tmp, region, scale, false)
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, ..Default::default() })
}
/// Pixels per point of raster effects rendered as images (the document's raster effects
@@ -83,17 +169,22 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 {
/// with `out`'s resources at `scale` over its visual bounds and the reach of its raster effects
/// (its own and its fills' and strokes'). With `knockout`, the coverage of that art is knocked out
/// of the image, which then only holds what the effects add around it (a shadow to go under the
/// vector object).
/// vector object). The document's raster effects settings add room around it and finish its pixels.
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
let b = whole.visual_bounds()?;
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&whole.appearance.effects), f64::max) + 2.0;
let look = out.raster_effects.clone();
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&whole.appearance.effects), f64::max)
+ 2.0
+ look.add_around;
let b = b.inflate(reach, reach);
// Whole pixels, and no larger than 64 Mpx.
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
let mut img = render(out, vec![whole.clone()], region, scale);
// Anti-alias off: hard edges (the effects themselves stay smooth).
let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
let mut img = render(out, vec![whole.clone()], region, scale, anti_alias);
if let Some(bare) = knockout {
let obj = render(out, vec![bare.clone()], region, scale);
let obj = render(out, vec![bare.clone()], region, scale, anti_alias);
for (px, o) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(obj.pixels.as_chunks::<4>().0) {
let keep = 1.0 - o[3] as f32 / 255.0;
for c in px.iter_mut() {
@@ -101,6 +192,8 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
}
}
}
// Document Raster Effects Settings: colour model, background.
look.finish_pixels(&mut img.pixels);
// Can't encode: keep the object as it is (vector, effects ignored) rather than fail the export.
let png = img.to_png().ok()?;
let id = out.alloc_id();