mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
Fix Drop Shadow Darkness in source color space
This commit is contained in:
@@ -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() };
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user