mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06: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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6a446b4fa3
commit
94f0f1b0d2
@@ -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()));
|
||||
}
|
||||
Reference in New Issue
Block a user