Effects: Drop Shadow has Darkness, the object's own colors with black added

The reference app's Drop Shadow has a Color and a Darkness, the
percentage of black added to the object's own colors. Drop Shadow had
only a Color, so every shadow was one color. Choosing one or the other,
and the defaults, are VectorCraft's own.

Drop Shadow now takes `shade` ("color" or "darkness", default "color")
and `darkness` (%, default 100). Its dialog and the Appearance panel's
inline editor show Color and Darkness as radio buttons. With Darkness
d, a CMYK document keeps the object's cyan, magenta and yellow and
raises its black from K to K + d × (1 − K), and an RGB document
multiplies each channel by 1 − d, so in RGB 0% gives the object's
color and 100% gives black. A shadow saved without these keys draws as
before. SVG export writes a Darkness shadow with feComponentTransfer
and SVG import reads it back; CSS export leaves it out.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 02:25:58 +07:00
committed by bflatastic
parent b5a0fee1ea
commit 720ce5ed73
29 changed files with 497 additions and 51 deletions
+2 -2
View File
@@ -308,8 +308,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"stylize.dropShadow",
"Drop Shadow…",
STYLIZE,
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}",
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}),
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)",
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}),
),
r(
"stylize.innerGlow",
+6
View File
@@ -21,6 +21,9 @@ pub enum RasterFx {
dy: f64,
blur: f64,
color: Color,
/// Darkness (0..1): the shadow is the object's own colors with this share of black added
/// instead of `color`; `None` when it is `color`.
darkness: Option<f32>,
},
OuterGlow {
mode: BlendMode,
@@ -111,6 +114,9 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
blur,
color: color_param(&p, Color::BLACK),
darkness: text(&p, "shade", "color")
.eq_ignore_ascii_case("darkness")
.then(|| (num(&p, "darkness", 100.0) / 100.0).clamp(0.0, 1.0) as f32),
},
"stylize.outerGlow" => RasterFx::OuterGlow {
mode: blend_mode(text(&p, "mode", "screen")),
+16 -1
View File
@@ -565,11 +565,12 @@ fn raster_effects_parse_and_outset() {
let r = raster_effects(&list);
assert_eq!(r.len(), 5);
match &r[0] {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => {
assert_eq!(*mode, vectorcraft_doc::color::BlendMode::Multiply);
assert!(close(*opacity as f64, 0.5, 1e-6));
assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0));
assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]);
assert_eq!(*darkness, None);
}
other => panic!("{other:?}"),
}
@@ -578,6 +579,20 @@ fn raster_effects_parse_and_outset() {
assert_eq!(outset(&[], vectorcraft_geom::Rect::ZERO), 0.0);
}
#[test]
fn drop_shadow_darkness_replaces_its_color_when_chosen() {
let darkness = |params: Value| match raster_effects(&[fx("stylize.dropShadow", params)]).as_slice() {
[RasterFx::DropShadow { darkness, .. }] => *darkness,
other => panic!("{other:?}"),
};
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 60})), Some(0.6));
assert_eq!(darkness(json!({"shade": "darkness"})), Some(1.0));
assert_eq!(darkness(json!({"shade": "darkness", "darkness": 250})), Some(1.0));
// Color is the default: a Darkness value alone leaves the shadow its color.
assert_eq!(darkness(json!({"darkness": 60})), None);
assert_eq!(darkness(json!({})), None);
}
#[test]
fn blend_mode_names() {
use vectorcraft_doc::color::BlendMode;
+1 -1
View File
@@ -344,7 +344,7 @@ fn style(filters: &[Filter]) -> ArtStyle {
}),
visible: true,
}),
// A shadow darkening the object's own colours isn't one effects describe.
// A shadow darkening the object's own colors is not read as Drop Shadow's Darkness.
SHADOW => plain = false,
_ => {}
}
+160 -23
View File
@@ -308,6 +308,27 @@ fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter {
})
}
/// The layer filter of a drop shadow with Darkness: the object's own pixels blurred.
fn shade_filter(blur: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: (blur / 2.0).max(0.0) as f32, edge_mode: EdgeMode::None })
}
/// How much darker (0..1) a drop shadow with Darkness `darkness` makes the values `ink` paints:
/// black added leaves the cyan, magenta and yellow plane of a CMYK document as it is and scales
/// the complemented black plane, as it scales screen colors, by 1 − `darkness`.
fn shade(ink: Ink, darkness: f32) -> f32 {
match ink {
Ink::Cmy => 0.0,
Ink::K | Ink::Display | Ink::RichBlack => darkness,
}
}
/// What a shadow or glow raster is made from ([`Made::pixels`]): the silhouette's alpha, or its
/// premultiplied RGBA when the shadow takes the object's own colors (Darkness).
fn silhouette(pm: &vello_cpu::Pixmap, colored: bool) -> Vec<u8> {
if colored { pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect() } else { pm.data().iter().map(|p| p.a).collect() }
}
fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
let [r, g, b] = ink.rgb(c);
peniko::Color::new([r, g, b, 1.0])
@@ -434,7 +455,7 @@ impl ShadowEntry {
/// was made draws from that raster ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]).
pub(crate) struct Made {
/// The content's pixels as drawn into the raster before the effects ran: premultiplied RGBA,
/// or their alpha alone under a shadow or glow.
/// or their alpha alone under a shadow or glow that is one color ([`silhouette`]).
pixels: Vec<u8>,
/// The effects run on them.
fx: Vec<RasterFx>,
@@ -684,9 +705,9 @@ impl Renderer {
owner: Option<&Arc<Node>>,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) -> bool {
let (mode, opacity, dx, dy, blur, color) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color),
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness),
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None),
_ => return false,
};
let linear = linear_key(f.view);
@@ -718,7 +739,8 @@ impl Renderer {
return false;
}
let (w, h) = (w as u16, h as u16);
// Silhouette (the object's painted alpha), offset by the shadow distance.
// Silhouette (the object's painted alpha, and its colors under Darkness), offset by
// the shadow distance.
let mut off = f.offscreen_context(w, h);
let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
let before = self.stats.moved;
@@ -730,15 +752,36 @@ impl Renderer {
off.render(&mut pm, &mut self.resources);
// What the raster is made from, for its object once it moves (`move_shadow`).
let made =
Made { pixels: pm.data().iter().map(|p| p.a).collect(), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None };
Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None };
let (wu, hu) = (w as usize, h as usize);
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
effects::pixel::blur_plane(&mut a, wu, hu, blur / 2.0 / f.px);
let [cr, cg, cb] = f.ink.rgb(&color);
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
for (px, av) in pm.data_mut().iter_mut().zip(&a) {
let al = (av * opacity).clamp(0.0, 255.0);
*px = vello_cpu::color::PremulRgba8 { r: tint(al, cr), g: tint(al, cg), b: tint(al, cb), a: al.round() as u8 };
let sigma = blur / 2.0 / f.px;
if let Some(darkness) = darkness {
// The object's own colors, blurred, with the black Darkness adds (premultiplied).
let keep = 1.0 - shade(f.ink, darkness);
let mut planes: [Vec<f32>; 4] = [
pm.data().iter().map(|p| p.r as f32).collect(),
pm.data().iter().map(|p| p.g as f32).collect(),
pm.data().iter().map(|p| p.b as f32).collect(),
pm.data().iter().map(|p| p.a as f32).collect(),
];
for plane in &mut planes {
effects::pixel::blur_plane(plane, wu, hu, sigma);
}
let [r, g, b, a] = &planes;
for (k, px) in pm.data_mut().iter_mut().enumerate() {
let al = (a[k] * opacity).clamp(0.0, 255.0).round();
let value = |plane: &Vec<f32>| (plane[k] * opacity * keep).round().clamp(0.0, al) as u8;
*px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 };
}
} else {
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
effects::pixel::blur_plane(&mut a, wu, hu, sigma);
let [cr, cg, cb] = f.ink.rgb(&color);
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
for (px, av) in pm.data_mut().iter_mut().zip(&a) {
let al = (av * opacity).clamp(0.0, 255.0);
*px = vello_cpu::color::PremulRgba8 { r: tint(al, cr), g: tint(al, cg), b: tint(al, cb), a: al.round() as u8 };
}
}
let image = std::sync::Arc::new(pm);
let anchor = reach.origin();
@@ -768,8 +811,9 @@ impl Renderer {
/// frame made for its object (the same id, effect slot and ink, at this zoom and rotation, with
/// the same `inputs`) when only the silhouette's place changed after that frame: made by the
/// same effect `fx` from a silhouette whose bounds have the size of `reach` (the content's
/// bounds moved by the shadow's offset), and whose alpha is the alpha of `n`'s silhouette drawn
/// into a raster placed as that one was against its anchor. The raster is kept as `n`'s, marked
/// bounds moved by the shadow's offset), and whose alpha (and colors, under Darkness) are those
/// of `n`'s silhouette drawn into a raster placed as that one was against its anchor
/// ([`silhouette`]). The raster is kept as `n`'s, marked
/// moved, and is drawn at the nearest whole pixel.
#[allow(clippy::too_many_arguments)]
fn move_shadow(
@@ -783,9 +827,9 @@ impl Renderer {
inputs: &Inputs,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
let (dx, dy) = match fx {
RasterFx::DropShadow { dx, dy, .. } => (*dx, *dy),
_ => (0.0, 0.0),
let (dx, dy, colored) = match fx {
RasterFx::DropShadow { dx, dy, darkness, .. } => (*dx, *dy, darkness.is_some()),
_ => (0.0, 0.0, false),
};
let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size());
let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| {
@@ -809,7 +853,7 @@ impl Renderer {
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
if !self.shadows.get(&old).is_some_and(|e| pm.data().iter().map(|p| p.a).eq(e.made.pixels.iter().copied())) {
if !self.shadows.get(&old).is_some_and(|e| silhouette(&pm, colored) == e.made.pixels) {
return;
}
if let Some(mut e) = self.shadows.remove(&old) {
@@ -1028,12 +1072,15 @@ impl Renderer {
{
continue;
}
let (mode, opacity, dx, dy, blur, color) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color),
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color),
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness),
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None),
_ => continue,
};
let filter = shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color));
let filter = match darkness {
Some(_) => shade_filter(blur),
None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)),
};
// The offset moves the content rather than the filter: vello_cpu drops layer content
// that lies entirely outside the viewport before filtering.
let reach = reach + Vec2::new(dx, dy);
@@ -1041,6 +1088,14 @@ impl Renderer {
c.set_transform(fr.view);
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
paint(r, c, &shifted(fr, dx, dy));
// Darkness: black over the object's own colors, keeping their alpha.
if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) {
c.set_transform(fr.view);
c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop));
c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black]));
c.fill_rect(&reach);
c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
}
c.pop_layer();
});
}
@@ -1702,6 +1757,80 @@ mod tests {
assert_eq!(lum(img.pixel(15, 15)), WHITE);
}
/// A red square's drop shadow at Darkness `darkness` (%), opaque and sharp, 10 pt down and
/// right; `shade` "color" or "darkness".
fn shaded_doc(mode: vectorcraft_doc::ColorMode, fill: Color, shade: &str, darkness: f64) -> Document {
let mut d = doc_with(
Rect::new(20.0, 20.0, 60.0, 60.0),
fill,
vec![(
"stylize.dropShadow",
json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal", "shade": shade, "darkness": darkness}),
)],
);
d.color_mode = mode;
d
}
/// Darkness: the shadow is the object's own color, each channel times 1 − darkness in an RGB
/// document (the reference app's guide gives the object's color at 0%,
/// <https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf> page 428), in the cached
/// raster and, at 6000 %, in the filter layer that a raster too large to cache is drawn with.
#[test]
fn drop_shadow_darkness_darkens_the_objects_own_color() {
let rgb = vectorcraft_doc::ColorMode::Rgb;
let red = Color::rgb(1.0, 0.0, 0.0);
// At 6000 %, the view's center is (65, 65), where only the shadow lies.
let zoomed = Affine::translate((50.0, 50.0)) * Affine::scale(60.0) * Affine::translate((-65.0, -65.0));
for (darkness, want) in [(0.0, 255), (50.0, 128), (100.0, 0)] {
let d = shaded_doc(rgb, red, "darkness", darkness);
let px = render(&d).pixel(66, 66);
assert!(px[0].abs_diff(want) <= 1 && px[1] == 0 && px[2] == 0, "{darkness}%: {px:?}");
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let px = Renderer::new().render(&d, 100, 100, zoomed, &opts).pixel(50, 50);
assert!(px[0].abs_diff(want) <= 1 && px[1] == 0 && px[2] == 0, "{darkness}% at 6000 %: {px:?}");
}
}
/// In a CMYK document Darkness adds black ink and keeps the object's cyan, magenta and yellow
/// (same page of the guide: at 100% an object with colors other than black gets a shadow of those colors under
/// full black, and a black object's shadow is 100% black).
#[test]
fn drop_shadow_darkness_adds_black_in_a_cmyk_document() {
let cmyk = vectorcraft_doc::ColorMode::Cmyk;
let inks = |d: &Document| {
let i = Renderer::new().render_region_inks(d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, &RenderOptions::default());
let at = (66 * 100 + 66) * 4;
[i[at], i[at + 1], i[at + 2], i[at + 3]]
};
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
for (darkness, k) in [(0.0, 0), (50.0, 128), (100.0, 255)] {
let got = inks(&shaded_doc(cmyk, red, "darkness", darkness));
assert!(got[0] == 0 && got[1] == 255 && got[2] == 255 && got[3].abs_diff(k) <= 1, "{darkness}%: {got:?}");
}
// Half black ink, darkened by half: K 50 + 50 × 50% = K 75.
let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 0.5), "darkness", 50.0));
assert!(got[..3] == [0, 0, 0] && got[3].abs_diff(191) <= 1, "{got:?}");
let got = inks(&shaded_doc(cmyk, Color::cmyk(0.0, 0.0, 0.0, 1.0), "darkness", 100.0));
assert_eq!(got, [0, 0, 0, 255]);
}
/// Color stays the default: a shadow without the new keys, and one with Color chosen and a
/// Darkness value, draw the same pixels, which a shadow with Darkness does not.
#[test]
fn a_drop_shadow_with_color_draws_as_before() {
let rgb = vectorcraft_doc::ColorMode::Rgb;
let red = Color::rgb(1.0, 0.0, 0.0);
let old = doc_with(
Rect::new(20.0, 20.0, 60.0, 60.0),
red,
vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))],
);
let before = render(&old).pixels;
assert_eq!(render(&shaded_doc(rgb, red, "color", 40.0)).pixels, before);
assert_ne!(render(&shaded_doc(rgb, red, "darkness", 40.0)).pixels, before);
}
#[test]
fn shadow_of_offscreen_object_is_not_culled() {
let d = doc_with(
@@ -2009,6 +2138,14 @@ mod tests {
doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2}))]);
assert_eq!(drag(&shadowed, &moved(&shadowed, false, &recolor)), (1, true), "a shadow under a new color");
assert_eq!(drag(&shadowed, &moved(&shadowed, false, &notch)), (0, true), "a shadow under a notch");
// A shadow with Darkness is made from the object's colors: recolored, it is made again.
let shaded = doc_with(
Rect::new(20.0, 20.0, 50.0, 46.0),
Color::rgb(0.9, 0.4, 0.1),
vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 2, "shade": "darkness", "darkness": 50}))],
);
assert_eq!(drag(&shaded, &moved(&shaded, false, &|_| {})), (1, true), "a shadow with Darkness, moved");
assert_eq!(drag(&shaded, &moved(&shaded, false, &recolor)), (0, true), "a shadow with Darkness under a new color");
}
/// A raster made over moved rasters counts as moved itself (#1257): a group with a drop shadow
+3 -1
View File
@@ -570,7 +570,8 @@ impl Rules<'_> {
}
}
/// Shadows and glows as `box-shadow` (type: `text-shadow`), a Gaussian blur as `filter`.
/// Shadows and glows as `box-shadow` (type: `text-shadow`), a Gaussian blur as `filter`. A drop
/// shadow with Darkness is left out and the art marked unsupported.
fn effects(&mut self, n: &Node, text: bool, art: &mut Art) {
let fx = &n.appearance.effects;
if fx.iter().any(|e| e.visible && !vectorcraft_effects::is_raster(&e.id)) {
@@ -580,6 +581,7 @@ impl Rules<'_> {
let mut filter = None;
for f in vectorcraft_effects::raster_effects(fx) {
match f {
RasterFx::DropShadow { darkness: Some(_), .. } => art.unsupported("drop shadows with Darkness"),
RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => {
shadows.push(format!("{} {} {} {}", self.len(dx), self.len(dy), self.len(blur), color_css(&color, opacity)))
}
+11 -1
View File
@@ -1581,7 +1581,8 @@ impl Writer<'_> {
}
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
/// per effect, the first effect innermost (the reference app's stacking order). Photoshop-style
/// per effect, the first effect innermost (the reference app's stacking order). A drop shadow
/// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style
/// filters have no SVG equivalent: they are left out with a warning (exports from the app turn
/// such objects into images first).
fn filtered(&mut self, n: &Node) {
@@ -1628,6 +1629,15 @@ impl Writer<'_> {
format!("<feFlood flood-color=\"{}\" flood-opacity=\"{}\"/>", c.to_hex(), fmt_num(o as f64, 3))
};
let body = match f {
// Darkness: the object blurred and moved, each color channel times 1 − darkness.
RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!(
"<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\"/><feComponentTransfer result=\"paint\"><feFuncR type=\"linear\" slope=\"{keep}\"/><feFuncG type=\"linear\" slope=\"{keep}\"/><feFuncB type=\"linear\" slope=\"{keep}\"/><feFuncA type=\"linear\" slope=\"{}\"/></feComponentTransfer><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
sd(*blur),
self.num(*dx),
self.num(*dy),
fmt_num(*opacity as f64, 3),
keep = fmt_num(1.0 - *darkness as f64, 3)
),
RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!(
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\" result=\"shadow\"/>{}<feComposite in2=\"shadow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
sd(*blur),
+66 -15
View File
@@ -2,14 +2,15 @@
//! source, its silhouette blurred, moved or inverted, a colour flood, a coloured silhouette…),
//! and the result recognised as one of the effects the canvas draws: Gaussian Blur, Drop Shadow,
//! Outer Glow, Inner Glow or Feather. That covers `feGaussianBlur`, `feDropShadow`, the usual
//! shadow chains (offset, blur, flood or colour matrix, composite, merge) and what our export
//! writes, so export → import keeps the effects.
//! shadow chains (offset, blur, flood or color matrix, composite, merge), the object moved and
//! blurred with its colors scaled by `feComponentTransfer` (a drop shadow with Darkness) and what
//! our export writes, so export → import keeps the effects.
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::json;
use usvg::filter::{ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction};
use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction};
use usvg::roxmltree;
use vectorcraft_color::Color;
use vectorcraft_doc::Effect;
@@ -52,8 +53,8 @@ impl Shape {
enum V {
/// `SourceGraphic`.
Graphic,
/// The source blurred (σ).
Blurred(f64),
/// The source blurred (σ), on linear RGB values when `true`.
Blurred(f64, bool),
/// A black silhouette.
Alpha(Shape),
Flood(Color, f32),
@@ -66,6 +67,16 @@ enum V {
},
/// The source inside its own blurred silhouette (σ).
Feathered(f64),
/// The source blurred (σ `sd`, on linear RGB values when `linear`), moved, its colors times
/// `keep` and its alpha times `opacity`: a drop shadow with Darkness 1 − `keep`.
Shade {
sd: f64,
linear: bool,
dx: f64,
dy: f64,
keep: f32,
opacity: f32,
},
/// Layers, bottom first.
Stack(Vec<V>),
}
@@ -78,17 +89,18 @@ impl V {
}
}
/// Blurred by σ `sd` (blurs add up as variances).
fn blur(self, sd: f64) -> Option<V> {
/// Blurred by σ `sd` (blurs add up as variances), on linear RGB values when `linear`.
fn blur(self, sd: f64, linear: bool) -> Option<V> {
let add = |a: f64| (a * a + sd * sd).sqrt();
Some(match self {
v if sd <= 0.0 => v,
V::Graphic => V::Blurred(sd),
V::Blurred(a) => V::Blurred(add(a)),
V::Graphic => V::Blurred(sd, linear),
V::Blurred(a, l) => V::Blurred(add(a), l || linear),
V::Alpha(s) => V::Alpha(Shape { sd: add(s.sd), ..s }),
V::Paint { shape, color, opacity, clipped: false } => {
V::Paint { shape: Shape { sd: add(shape.sd), ..shape }, color, opacity, clipped: false }
}
V::Shade { sd: s, linear: l, dx, dy, keep, opacity } => V::Shade { sd: add(s), linear: l || linear, dx, dy, keep, opacity },
_ => return None,
})
}
@@ -98,11 +110,26 @@ impl V {
Some(match self {
V::Alpha(s) => V::Alpha(moved(s)),
V::Paint { shape, color, opacity, clipped: false } => V::Paint { shape: moved(shape), color, opacity, clipped: false },
v @ (V::Graphic | V::Blurred(_)) if dx == 0.0 && dy == 0.0 => v,
v @ (V::Graphic | V::Blurred(..)) if dx == 0.0 && dy == 0.0 => v,
V::Graphic => V::Shade { sd: 0.0, linear: false, dx, dy, keep: 1.0, opacity: 1.0 },
V::Blurred(sd, linear) => V::Shade { sd, linear, dx, dy, keep: 1.0, opacity: 1.0 },
V::Shade { sd, linear, dx: x, dy: y, keep, opacity } => V::Shade { sd, linear, dx: x + dx, dy: y + dy, keep, opacity },
_ => return None,
})
}
/// The source, blurred or moved, with each color channel times `keep` and its alpha times
/// `opacity` (`feComponentTransfer` with linear functions through 0).
fn scaled(self, keep: f32, opacity: f32) -> Option<V> {
let (sd, linear, dx, dy, k, o) = match self {
V::Graphic => (0.0, false, 0.0, 0.0, 1.0, 1.0),
V::Blurred(sd, linear) => (sd, linear, 0.0, 0.0, 1.0, 1.0),
V::Shade { sd, linear, dx, dy, keep, opacity } => (sd, linear, dx, dy, keep, opacity),
_ => return None,
};
Some(V::Shade { sd, linear, dx, dy, keep: k * keep, opacity: o * opacity })
}
/// `self` in the alpha of `mask` (`feComposite operator="in"`).
fn inside(self, mask: V) -> Option<V> {
// The source's alpha is its silhouette.
@@ -124,7 +151,7 @@ impl V {
fn color_matrix(self, m: &[f32]) -> Option<V> {
let shape = match self {
V::Alpha(s) => s,
V::Blurred(sd) => Shape { sd, ..Shape::default() },
V::Blurred(sd, _) => Shape { sd, ..Shape::default() },
_ => return None,
};
let row = |r: usize| m.get(r * 5..r * 5 + 5);
@@ -163,7 +190,10 @@ fn eval(f: &Filter) -> Option<V> {
}
};
let v = match p.kind() {
Kind::GaussianBlur(b) => input(b.input())?.blur(sd(b.std_dev_x().get(), b.std_dev_y().get()))?,
Kind::GaussianBlur(b) => {
let linear = p.color_interpolation() == ColorInterpolation::LinearRGB;
input(b.input())?.blur(sd(b.std_dev_x().get(), b.std_dev_y().get()), linear)?
}
Kind::Offset(o) => input(o.input())?.offset(o.dx() as f64, o.dy() as f64)?,
Kind::Flood(fl) => V::Flood(color(fl.color()), fl.opacity().get()),
Kind::Composite(c) => {
@@ -186,8 +216,22 @@ fn eval(f: &Filter) -> Option<V> {
TransferFunction::Linear { slope, intercept } => *slope == -1.0 && *intercept == 1.0,
_ => false,
};
match input(c.input())? {
V::Alpha(s) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }),
// A scale (a linear function through 0, or none) of each channel.
let scale = |f: &TransferFunction| match f {
TransferFunction::Identity => Some(1.0),
TransferFunction::Linear { slope, intercept } if *intercept == 0.0 && (0.0..=1.0).contains(slope) => Some(*slope),
_ => None,
};
// The same scale of R, G and B, on sRGB values (a scale of 0 or 1 is the same on
// linear ones).
let srgb = p.color_interpolation() == ColorInterpolation::SRGB;
let keep = match [c.func_r(), c.func_g(), c.func_b()].map(scale) {
[Some(r), Some(g), Some(b)] if r == g && g == b && (srgb || r == 0.0 || r == 1.0) => Some(r),
_ => None,
};
match (input(c.input())?, keep, scale(c.func_a())) {
(V::Alpha(s), _, _) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }),
(v @ (V::Graphic | V::Blurred(..) | V::Shade { .. }), Some(keep), Some(opacity)) => v.scaled(keep, opacity)?,
_ => return None,
}
}
@@ -219,7 +263,7 @@ pub(super) fn effects(filters: &[Arc<Filter>], ts: Affine, blends: &HashMap<Stri
let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0));
let (id, params) = match eval(f)? {
V::Graphic => continue,
V::Blurred(sd) => ("blur.gaussian", json!({ "radius": blur(sd) })),
V::Blurred(sd, _) => ("blur.gaussian", json!({ "radius": blur(sd) })),
V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })),
V::Stack(layers) => match layers.as_slice() {
[V::Paint { shape, color, opacity, clipped: false }, V::Graphic] if !shape.inverted => {
@@ -232,6 +276,13 @@ pub(super) fn effects(filters: &[Arc<Filter>], ts: Affine, blends: &HashMap<Stri
("stylize.outerGlow", json!({ "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode }))
}
}
// A drop shadow with Darkness below 100% blurs the object's sRGB values.
[V::Shade { sd, linear, dx, dy, keep, opacity }, V::Graphic] if !*linear || *keep == 0.0 => {
let v = along(*dx, *dy);
let (darkness, opacity) = (num((1.0 - *keep as f64) * 100.0), num(*opacity as f64 * 100.0));
let p = json!({ "x": num(v.x), "y": num(v.y), "blur": blur(*sd), "shade": "darkness", "darkness": darkness, "opacity": opacity, "mode": mode });
("stylize.dropShadow", p)
}
[V::Graphic, V::Paint { shape, color, opacity, clipped: true }] if !shape.moved => {
let (color, opacity) = paint(*color, *opacity);
let source = if shape.inverted { "edge" } else { "center" };
+10
View File
@@ -220,6 +220,16 @@ fn shadows_and_unsupported_art() {
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
assert_eq!(prop(&r, "box-shadow"), Some("2px 3px 4px rgba(0, 0, 0, 0.5)"));
// A shadow with Darkness takes the object's own colors, which a CSS shadow can't.
let mut n = rect(&mut d, (0.0, 0.0, 50.0, 50.0), 0.0);
n.appearance.effects.push(Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 2, "y": 3, "blur": 4, "opacity": 50, "shade": "darkness"}),
visible: true,
});
let shaded = add(&mut d, n);
let r = one(&d, shaded, &CssOptions::default());
assert!(prop(&r, "box-shadow").is_none() && r.unsupported.is_some(), "{r:?}");
// A star is no box: described as its box, or with rasterize as its picture.
let star = Node::path(
d.alloc_id(),
+57 -1
View File
@@ -184,8 +184,9 @@ fn a_gaussian_blur_becomes_the_effect_with_its_radius() {
fn shadow(d: &Document) -> (f64, f64, f64, Color, f32) {
let fx = vectorcraft_effects::raster_effects(&effects(d));
match fx.as_slice() {
[RasterFx::DropShadow { dx, dy, blur, color, opacity, mode }] => {
[RasterFx::DropShadow { dx, dy, blur, color, opacity, mode, darkness }] => {
assert_eq!(*mode, BlendMode::Normal, "filters composite normally");
assert_eq!(*darkness, None);
(*dx, *dy, *blur, *color, (opacity * 100.0).round())
}
other => panic!("{other:?}"),
@@ -214,6 +215,38 @@ fn drop_shadow_chains_become_drop_shadows() {
}
}
/// The object blurred, moved and its colors scaled under it is a drop shadow with Darkness when
/// the blur and the scale work on sRGB values, or when the scale is 0; otherwise, on linear RGB
/// values (the SVG default), it is reported. A blur on linear values whose result goes unused
/// does not count.
#[test]
fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() {
let chain = |space: &str, transfer: &str, slope: f64| {
format!(
r##"<defs><filter id="s"{space}><feGaussianBlur in="SourceGraphic" stdDeviation="3" color-interpolation-filters="linearRGB" result="unused"/><feGaussianBlur in="SourceGraphic" stdDeviation="2"/><feOffset dx="4" dy="5"/><feComponentTransfer result="p"{transfer}><feFuncR type="linear" slope="{slope}"/><feFuncG type="linear" slope="{slope}"/><feFuncB type="linear" slope="{slope}"/><feFuncA type="linear" slope="0.6"/></feComponentTransfer><feMerge><feMergeNode in="p"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect x="50" y="30" width="60" height="40" fill="#f00" filter="url(#s)"/>"##
)
};
let srgb = r#" color-interpolation-filters="sRGB""#;
// The scale 0 makes black whatever values it works on.
for (space, slope, darkness) in [(srgb, 0.5, 50.0), ("", 0.0, 100.0)] {
let (d, w) = open(&chain(space, "", slope));
assert!(w.is_empty(), "{w:?}");
let fx = effects(&d);
let p = &fx[0].params;
assert_eq!(
(fx[0].id.as_str(), &p["shade"], &p["darkness"], &p["opacity"]),
("stylize.dropShadow", &json!("darkness"), &json!(darkness), &json!(60.0))
);
assert_eq!((&p["x"], &p["y"], &p["blur"]), (&json!(4.0), &json!(5.0), &json!(4.0)));
}
// Scaled on linear values, or blurred on them before an sRGB scale.
for (space, transfer) in [("", ""), ("", srgb)] {
let (d, w) = open(&chain(space, transfer, 0.5));
assert!(w.iter().any(|w| w.contains("filter")), "{transfer}: {w:?}");
assert!(effects(&d).is_empty());
}
}
#[test]
fn other_filters_are_reported() {
let (d, w) =
@@ -239,6 +272,7 @@ fn exported_effects_import_as_the_same_effects() {
let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true };
let cases = [
vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "color": "#203040"}))],
vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "shade": "darkness", "darkness": 40.0}))],
vec![fx("stylize.outerGlow", json!({"blur": 8.0, "color": "#ffcc00", "opacity": 80.0}))],
vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#ffffff", "source": "edge", "mode": "normal"}))],
vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#00ff00", "source": "center", "mode": "multiply"}))],
@@ -259,6 +293,28 @@ fn exported_effects_import_as_the_same_effects() {
}
}
#[test]
fn a_drop_shadow_with_darkness_exports_as_the_canvas_draws_it() {
let mut d = Document::new(200.0, 120.0);
let mut n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)),
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
);
let shadow = json!({"x": 30.0, "y": 30.0, "blur": 0.0, "opacity": 100.0, "mode": "normal", "shade": "darkness", "darkness": 50.0});
n.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }];
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let svg = export(&d, &ExportOptions::default());
let (canvas, exported) = (render_artboard(&d), resvg_render(&svg, 200, 120));
// Where only the shadow lies: the red at half its value.
for img in [&canvas, &exported] {
let [r, g, b] = img.over_white(115, 90);
assert!(r.abs_diff(128) <= 2 && g <= 2 && b <= 2, "{:?}", [r, g, b]);
}
assert_similar(&canvas, &exported, 40.0, 0.02);
}
#[test]
fn symbols_and_effects_export_import_export_is_stable() {
// A doc with a symbol instance carrying a shadow: export → import → export is stable.
+52 -3
View File
@@ -139,6 +139,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()),
None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()),
None if id == "artistic.filmGrain" => film_grain_fields(ui, d),
None => {
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
@@ -197,6 +198,7 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]),
("stylize.innerGlow", "source") => Some(&[("Center", "center"), ("Edge", "edge")]),
("stylize.dropShadow", "shade") => Some(SHADES),
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]),
_ => None,
@@ -385,6 +387,49 @@ fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit
values_changed(d)
}
/// Drop Shadow (Effect › Stylize › Drop Shadow…).
const DROP_SHADOW: &str = "stylize.dropShadow";
/// What Drop Shadow's shadow takes its color from (`shade`), as (label, value).
pub(crate) const SHADES: form::Choices = &[("Color", "color"), ("Darkness", "darkness")];
/// Drop Shadow's dialog: Mode, Opacity, the offsets and Blur as the other effects show them, then
/// Color and Darkness as radio buttons, each with its field (the field of the one not chosen is
/// dimmed). Returns whether the values differ from those last previewed.
fn drop_shadow_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool {
let doc = vectorcraft_effects::effect_info(DROP_SHADOW).map(|e| e.params).unwrap_or_default();
// The generic rows, without the three this dialog shows as radio buttons.
let own: Vec<(String, Value)> = ["shade", "color", "darkness"].iter().filter_map(|k| d.fields.remove(*k).map(|v| (k.to_string(), v))).collect();
form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(DROP_SHADOW, k, false), &|k| choices(DROP_SHADOW, k), &|k| doc_rank(doc, k), unit);
d.fields.extend(own);
let darkness = d.str("shade").eq_ignore_ascii_case("darkness");
ui.add_space(4.0);
egui::Grid::new("fx-shade").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
if widgets::radio(ui, tl!("Color"), !darkness, true) {
d.fields.insert("shade".into(), json!("color"));
}
ui.add_enabled_ui(!darkness, |ui| match d.fields.get("color") {
Some(Value::String(_)) | None => {
form::text(ui, d, "color", 128.0);
}
Some(other) => {
widgets::dim_label(ui, &other.to_string());
}
});
ui.end_row();
if widgets::radio(ui, tl!("Darkness"), darkness, true) {
d.fields.insert("shade".into(), json!("darkness"));
}
ui.add_enabled_ui(darkness, |ui| {
if let Some(v) = widgets::range_field(ui, "fx-darkness", d.f64("darkness", 100.0), 0.0..=100.0, "%", 0, 70.0) {
d.fields.insert("darkness".into(), json!(v));
}
});
ui.end_row();
});
values_changed(d)
}
/// Curves' graph of its `points` (input across, output up, 0..255): drag a point to move it
/// between its neighbours, press on the graph away from the points to add one there, drag one
/// off the graph to remove it (two always stay).
@@ -499,9 +544,13 @@ mod tests {
#[test]
fn fields_that_pick_a_value_are_dropdowns_of_its_values() {
// The values come from the effect's parameter documentation (`joins: "miter"|"round"|…`).
for (effect, key) in
[("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points"), ("stylize.innerGlow", "source")]
{
for (effect, key) in [
("path.offsetPath", "joins"),
("distort.zigZag", "points"),
("distort.roughen", "points"),
("stylize.innerGlow", "source"),
(DROP_SHADOW, "shade"),
] {
let doc = vectorcraft_effects::effect_info(effect).unwrap().params;
let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().to_string();
let values: Vec<String> = choices(effect, key).unwrap().iter().map(|(_, v)| format!("\"{v}\"")).collect();
+1 -1
View File
@@ -103,8 +103,8 @@ use serde_json::{Value, json};
pub use color_picker::open as open_color_picker;
pub use document_setup::open as open_document_setup;
pub(crate) use effect::FILM_GRAIN_FIELDS;
pub use effect::open as open_effect_dialog;
pub(crate) use effect::{FILM_GRAIN_FIELDS, SHADES};
pub use export_as::open as open_export_as;
pub use export_for_screens::open as open_export_for_screens;
pub(crate) use export_for_screens::{
+45
View File
@@ -748,6 +748,51 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga
assert!(text.lines().any(|l| l == "Reference Point"), "{text}");
}
/// Drop Shadow's dialog has Color and Darkness as radio buttons, each with its field; a click on
/// Darkness previews the shadow with it and OK applies it.
#[test]
fn drop_shadow_chooses_color_or_darkness() {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("effect.dialog", json!({"effect": "stylize.dropShadow"})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
out
};
run(&mut app, vec![]);
let out = run(&mut app, vec![]);
let text = crate::tests_labels::shapes_text(&out);
let lines: Vec<&str> = text.lines().map(str::trim).collect();
for want in ["Mode:", "Opacity:", "Blur:", "Color", "Darkness"] {
assert!(lines.contains(&want), "no {want:?} in {lines:?}");
}
assert!(!lines.contains(&"Shade:"), "{lines:?}");
fn find(shape: &egui::Shape) -> Option<egui::Pos2> {
match shape {
egui::Shape::Text(t) if t.galley.text().trim() == "Darkness" => Some(t.visual_bounding_rect().center()),
egui::Shape::Vec(v) => v.iter().find_map(find),
_ => None,
}
}
let at = out.shapes.iter().find_map(|s| find(&s.shape)).unwrap();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)], vec![], vec![]] {
run(&mut app, events);
}
let effect = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].clone();
assert!(app.session.in_interaction(), "the preview runs");
assert_eq!(effect(&app).params["shade"], json!("darkness"));
confirm(&mut app).unwrap();
let params = effect(&app).params;
assert_eq!((params["shade"].clone(), params["darkness"].clone()), (json!("darkness"), json!(100.0)));
}
#[test]
fn artboard_options_position_moves_art_only_with_the_tool_option_on() {
for move_art in [false, true] {
+1
View File
@@ -436,6 +436,7 @@
Round Corners… Zaoblit rohy…
Scribble… Čmáranice…
Drop Shadow… Vržený stín…
Darkness Tmavost
Inner Glow… Vnitřní záře…
Outer Glow… Vnější záře…
Feather… Měkké okraje…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -638,6 +638,7 @@
Cyan Cyan
Dark Dunkel
Darken Abdunkeln
Darkness Dunkelheit
dash Strich
Dash Pattern Strichmuster
Dashed Gestrichelt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -652,6 +652,7 @@
Cyan Cian
Dark Oscuro
Darken Oscurecer
Darkness Oscuridad
dash guion
Dash Pattern Patrón de guiones
Dashed Discontinuo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -654,6 +654,7 @@
Cyan Cyan
Dark Sombre
Darken Obscurcir
Darkness Obscurité
dash tiret
Dash Pattern Motif de tirets
Dashed Pointillés
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -867,6 +867,7 @@
Dark Strokes Sötét vonások
Dark Strokes… Sötét vonások…
Darken Sötétítés
Darkness Sötétség
dash vonal
Dash Pattern Szaggatás mintázata
Dashed Szaggatott
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -652,6 +652,7 @@
Cyan Ciano
Dark Scuro
Darken Scurisci
Darkness Oscurità
dash trattino
Dash Pattern Schema del tratteggio
Dashed Tratteggiato
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1135,6 +1135,7 @@
DXF Options DXF オプション
DXF Options Dialog DXF オプションダイアログ
Darken 比較 (暗)
Darkness 濃さ
Dashed 破線
Dashed Line 破線
Data Recovery データ復帰
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1130,6 +1130,7 @@
DXF Options DXF 옵션
DXF Options Dialog DXF 옵션 대화상자
Darken 어둡게
Darkness 어둡기
Dashed 점선
Dashed Line 점선
Data Recovery 데이터 복구
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -396,6 +396,7 @@
Round Corners… Zaokrąglij narożniki…
Scribble… Bazgroły…
Drop Shadow… Cień…
Darkness Zaciemnienie
Inner Glow… Blask wewnętrzny…
Outer Glow… Blask zewnętrzny…
Feather… Wtapianie…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -650,6 +650,7 @@
Cyan Голубой
Dark Тёмный
Darken Замена тёмным
Darkness Затемнение
dash штрих
Dash Pattern Шаблон штрихов
Dashed Штриховой
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -646,6 +646,7 @@
Cyan Блакитний
Dark Темний
Darken Затемнення
Darkness Темність
dash штрих
Dash Pattern Шаблон штрихів
Dashed Штриховий
1 # Ukrainian (uk) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation.
646
647
648
649
650
651
652
+1
View File
@@ -764,6 +764,7 @@
Dark Strokes Nét tối
Dark Strokes… Nét tối…
Darken Làm tối
Darkness Độ tối
dash nét đứt
Dash Pattern Mẫu nét đứt
Dashed Nét đứt
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
764
765
766
767
768
769
770
+1
View File
@@ -610,6 +610,7 @@
Cyan 青
Dark 深色
Darken 变暗
Darkness 暗度
dash 虚线
Dash Pattern 虚线样式
Dashed 虚线
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -625,6 +625,7 @@
Cyan 青色
Dark 深色
Darken 變暗
Darkness 暗度
dash 虛線
Dash Pattern 虛線樣式
Dashed 虛線
Can't render this file because it contains an unexpected character in line 6 and column 47.
+51 -2
View File
@@ -724,8 +724,8 @@ fn has_options(id: &str) -> bool {
vectorcraft_effects::default_params(id).is_some_and(|d| d.as_object().is_some_and(|o| !o.is_empty()))
}
/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colours);
/// edits go through `effect.setParams`.
/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colors, and
/// Drop Shadow's Color or Darkness as radio buttons); edits go through `effect.setParams`.
fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usize, e: &Effect) {
let t = Tokens::get(ui.ctx());
let defaults = vectorcraft_effects::default_params(&e.id).unwrap_or(Value::Null);
@@ -755,6 +755,15 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
}
return;
}
if e.id == "stylize.dropShadow" && key == "shade" {
let cur = v.as_str().unwrap_or_default();
for (label, value) in crate::dialogs::SHADES {
if widgets::radio(ui, label, cur.eq_ignore_ascii_case(value), true) && !cur.eq_ignore_ascii_case(value) {
change = Some((key.clone(), json!(value)));
}
}
return;
}
match v {
Value::Bool(b) => {
if widgets::check(ui, "", *b, true) {
@@ -980,6 +989,46 @@ mod tests {
assert_eq!(shown_effect_label("plugin.example.glow"), effect_label("plugin.example.glow"));
}
/// A Drop Shadow's inline editor has Color and Darkness as radio buttons in its Shade row; a
/// click on Darkness sets the effect's `shade`.
#[test]
fn the_drop_shadow_editor_chooses_color_or_darkness() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.1;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out
};
run(&mut app, vec![]);
let out = run(&mut app, vec![]);
// The Shade row's Darkness, below the Darkness value's own row.
fn find(s: &egui::Shape, out: &mut Vec<egui::Pos2>) {
match s {
egui::Shape::Text(t) if t.galley.text() == "Darkness" => out.push(t.visual_bounding_rect().center()),
egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, out)),
_ => {}
}
}
let mut found = vec![];
out.shapes.iter().for_each(|c| find(&c.shape, &mut found));
assert_eq!(found.len(), 2, "{found:?}");
let at = found.into_iter().max_by(|a, b| a.y.total_cmp(&b.y)).unwrap();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
run(&mut app, events);
}
let n = app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
assert_eq!(n.appearance.effects[0].params["shade"], json!("darkness"));
}
#[test]
fn drops_pick_the_command() {
use vectorcraft_doc::{Appearance, FillLayer};
+2
View File
@@ -578,6 +578,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, Document Setup's substitution highlights, a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536).
- **Drop Shadow's Darkness:** Effect › Stylize › Drop Shadow… has Color and Darkness as radio buttons, each with its field, and the Appearance panel's inline editor has the two radio buttons in its Shade row (`shade`: `color` or `darkness`; `darkness`: 0 to 100%, 100 by default). Color is the default, and a shadow saved without these keys draws as before. With Darkness the shadow is the object's own colors with black added: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K), and in an RGB document each channel is multiplied by 1 − darkness. Darkness 0% gives the object's color; 100% gives black in an RGB document and keeps the colors under 100% black in a CMYK one. SVG export writes the object blurred, moved and darkened with `feComponentTransfer` (screen colors, in a CMYK document too), and SVG import reads it back; CSS export leaves the shadow out and marks the object unsupported. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 428 (PDF page 434).
- **Live objects after document changes:** a blend, envelope or repeat, with or without an effect of its own, is evaluated again when one of these changes: the tiling of a pattern whose tiles an envelope bends (Distort Pattern Fills), a symbol shown inside a symbol, a pattern or an opacity mask, the fonts of its type, a placed document's file once the file is read, and the color settings. A gradient mesh, with or without an effect of its own, is colored again when the color settings change.
- **Full font names (#1258):** OpenType name ID 4 identifies its face alongside family/legacy/PostScript names, including localized names, for loaded fonts, cataloged files and Missing Fonts searches. Synthetic name-table regressions cover full names distinct from their PostScript name, real-family precedence and variable fonts' default faces. Windows font-service activation remains unverified; scores and hours are unchanged.