Fix Drop Shadow Darkness in source color space

This commit is contained in:
dotCooCoo
2026-10-29 14:36:41 +07:00
committed by bflatastic
parent f55cbf313c
commit dfa9f883c0
21 changed files with 587 additions and 118 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
let regions: Option<Vec<vectorcraft_geom::Rect>> =
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
// Render effects SVG cannot represent faithfully, including source-model Darkness shadows.
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
let doc = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded { warnings, ..Encoded::default() };
+92
View File
@@ -297,3 +297,95 @@ fn svgz_opens_and_pastes_like_svg() {
assert_eq!(r["count"], 2);
assert!(s.execute("document.open", &json!({"name": "bad.svgz", "dataBase64": vectorcraft_format::base64_encode(&[0x1f, 0x8b, 1, 2])})).is_err());
}
#[test]
fn svg_darkness_preserves_source_model_and_gradient_appearance() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{ColorMode, Effect};
use vectorcraft_geom::Rect;
for kind in ["rgb", "cmykSource", "gradient", "cmykDocument"] {
let mut s = session(1);
s.edit("paint source", |d, _| {
if kind == "cmykDocument" {
d.color_mode = ColorMode::Cmyk;
}
let id = d.layers[0].children().unwrap()[0].id;
let n = d.node_mut(id).unwrap();
n.appearance.fill_mut().unwrap().paint = match kind {
"cmykSource" => Paint::solid(Color::cmyk(0.15, 0.3, 0.4, 0.2)),
"gradient" => Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(0.8, 0.4, 0.2))],
)))),
_ => Paint::solid(Color::rgb(0.8, 0.4, 0.2)),
};
n.appearance.items.retain(|i| i.is_fill());
n.appearance.effects = vec![Effect {
id: "stylize.dropShadow".into(),
visible: true,
params: json!({"shade":"darkness","darkness":25,"x":60,"y":0,"blur":0,"opacity":100,"mode":"normal"}),
}];
Ok(())
})
.unwrap();
let native = s.doc().unwrap().doc.clone();
let text = svg(&mut s, json!({}));
if kind == "rgb" {
assert!(text.contains("feComponentTransfer") && !text.contains("<image"));
} else {
assert!(text.contains("<image"), "{kind}: the shadow must preserve rendered appearance");
}
let back = vectorcraft_svg::import(&text).unwrap();
let region = Rect::new(0.0, 0.0, 200.0, 100.0);
let expected = vectorcraft_render::Renderer::new().render_region(&native, region, 1.0, true);
let actual = vectorcraft_render::Renderer::new().render_region(&back, region, 1.0, true);
for x in 80..115 {
let (a, b) = (actual.pixel(x, 30), expected.pixel(x, 30));
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 8), "{kind} at {x}: {a:?} vs {b:?}");
}
}
}
#[test]
fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() {
const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE";
if std::env::var_os(CHILD).is_none() {
// Color settings are process-wide: exercise this profile in a separate test process.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
return;
}
use vectorcraft_color::{Color, Paint, cms};
let settings = cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() };
cms::set_active(&settings).unwrap();
assert!(!cms::active().rgb_is_srgb());
let mut s = session(1);
s.edit("RGB source", |d, _| {
let id = d.layers[0].children().unwrap()[0].id;
let n = d.node_mut(id).unwrap();
n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.8, 0.4, 0.2));
n.appearance.items.retain(|i| i.is_fill());
n.appearance.effects = vec![vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
visible: true,
params: json!({"shade":"darkness","darkness":25,"x":60,"y":0,"blur":0,"opacity":100,"mode":"normal"}),
}];
Ok(())
})
.unwrap();
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0);
let expected = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 1.0, true);
let text = svg(&mut s, json!({}));
assert!(text.contains("<image"), "a non-sRGB source must preserve its adjusted rendered colors");
cms::set_active(&cms::ColorSettings::default()).unwrap();
let back = vectorcraft_svg::import(&text).unwrap();
let actual = vectorcraft_render::Renderer::new().render_region(&back, region, 1.0, true);
for x in 80..115 {
let (a, b) = (actual.pixel(x, 30), expected.pixel(x, 30));
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 8), "at {x}: {a:?} vs {b:?}");
}
}
+31 -7
View File
@@ -11,8 +11,9 @@
//! images with the same helpers.
//!
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying
//! one of those into images the same way ([`flatten_pixel_effects`]).
//! effects (Radial Blur, Unsharp Mask…) or Rasterize. Darkness shadows also need rendered images
//! when source colors or gradients cannot be represented by an RGB component-transfer filter.
//! SVG export prepares these images with [`flatten_pixel_effects`].
use std::sync::Arc;
@@ -452,9 +453,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
enum Which {
/// Every raster effect (PDF).
All,
/// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their
/// raster effects).
Pixel,
/// Effects SVG cannot represent faithfully, with their other raster effects.
Pixel { cmyk: bool },
}
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
@@ -462,10 +462,34 @@ fn imaged(n: &Node, which: Which) -> bool {
let fx = raster_fx(n);
match which {
Which::All => !fx.is_empty(),
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))),
Which::Pixel { cmyk } => {
fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)))
|| (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx)))
}
}
}
/// A filter on one uniform RGB paint can express the source-color adjustment exactly.
/// Other paints need the existing rendered-effect image to keep their source-model semantics.
fn simple_rgb_shadow_source(n: &Node, fx: &[RasterFx]) -> bool {
if !vectorcraft_color::cms::active().rgb_is_srgb() || !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || fx.len() != 1 {
return false;
}
// Other effects can generate varying source colors even from a single plain paint.
let only_shadow = std::iter::once(&n.appearance.effects)
.chain(n.appearance.items.iter().map(AppearanceItem::effects))
.flatten()
.all(|e| !e.visible || e.id == "stylize.dropShadow");
if !only_shadow || n.extras().mask.is_some() {
return false;
}
let mut paints = n.appearance.items.iter().filter(|i| effects::paints(i));
let Some(item) = paints.next() else {
return false;
};
paints.next().is_none() && matches!(item.paint(), Paint::Solid { color: vectorcraft_color::Color::Rgb { .. }, swatch: None, .. })
}
/// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes',
/// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers
/// aren't drawn.
@@ -566,7 +590,7 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its
/// filters draw the other raster effects live), or `None` when there are none.
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk }, doc))
}
fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {