mirror of
https://github.com/penpot/penpot.git
synced 2026-09-15 00:19:01 +00:00
✨ Export drop and inner shadows to WASM SVG (#11593)
SkSVGDevice drops save_layer image-filters, so re-emit visible drop and inner shadows (plus optional layer blur) as one native SVG filter chain matching classic order: flood, drops, SourceGraphic, inners, blur. Closes #11379
This commit is contained in:
parent
f3da8af7b6
commit
3adf9ade14
@ -1,6 +1,6 @@
|
||||
use skia_safe::{self as skia, Paint};
|
||||
|
||||
use crate::shapes::{radius_to_sigma, Shape, Type};
|
||||
use crate::shapes::{radius_to_sigma, Shadow, Shape, Type};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use crate::render::vector::draw_shape_geometry;
|
||||
@ -25,6 +25,17 @@ pub(crate) struct SvgLayerCanvas {
|
||||
pending: Option<skia::svg::Canvas>,
|
||||
next_id: usize,
|
||||
frag_no: usize,
|
||||
/// When true, skip SVG `<filter>` effects so a parent drop-shadow pass can
|
||||
/// sample silhouettes without nested child shadows (shadow-of-shadow).
|
||||
pub(super) suppress_filters: bool,
|
||||
/// Design-space outset applied while drawing a container drop-shadow
|
||||
/// silhouette. Matches GPU geometric spread (avoids `feMorphology` fattening
|
||||
/// stroke rings on both edges).
|
||||
pub(super) silhouette_spread: f32,
|
||||
/// Design-space drop offset applied in local shape space while drawing a
|
||||
/// container silhouette (GPU `pre_translate` before rotation). The SVG
|
||||
/// filter itself uses a zero offset so rotated shadows stay correct.
|
||||
pub(super) silhouette_offset: (f32, f32),
|
||||
}
|
||||
|
||||
impl SvgLayerCanvas {
|
||||
@ -39,9 +50,23 @@ impl SvgLayerCanvas {
|
||||
pending: None,
|
||||
next_id: 0,
|
||||
frag_no: 0,
|
||||
suppress_filters: false,
|
||||
silhouette_spread: 0.0,
|
||||
silhouette_offset: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// CTM for silhouette geometry: original centered transform, then local
|
||||
/// drop offset (spread is applied by outsetting selrect separately).
|
||||
pub(super) fn silhouette_draw_matrix(&self, element: &Shape) -> skia::Matrix {
|
||||
let mut matrix = element.centered_transform();
|
||||
let (dx, dy) = self.silhouette_offset;
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
matrix.pre_translate((dx, dy));
|
||||
}
|
||||
matrix
|
||||
}
|
||||
|
||||
pub(super) fn unique(&mut self, prefix: &str) -> String {
|
||||
let id = format!("{prefix}{}", self.next_id);
|
||||
self.next_id += 1;
|
||||
@ -103,11 +128,11 @@ impl SvgLayerCanvas {
|
||||
self.out.push_str(markup);
|
||||
}
|
||||
|
||||
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
|
||||
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
|
||||
/// CTM for leaf content placed in page space: Scale * Translate * `draw_matrix`.
|
||||
pub(super) fn page_draw_matrix_attr(&self, draw_matrix: &skia::Matrix) -> String {
|
||||
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
|
||||
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
|
||||
ctm = ctm * shape.centered_transform();
|
||||
ctm = ctm * *draw_matrix;
|
||||
format!(
|
||||
"matrix({} {} {} {} {} {})",
|
||||
ctm.scale_x(),
|
||||
@ -123,6 +148,9 @@ impl SvgLayerCanvas {
|
||||
///
|
||||
/// A mask can be a group too. Since a group has no geometry of its own, we
|
||||
/// recurse into its descendants and accumulate their geometry.
|
||||
///
|
||||
/// Uses [`Self::silhouette_offset`] so clipped container drop silhouettes
|
||||
/// move their clip with the offset content (GPU parity).
|
||||
pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) {
|
||||
let canvas = self.new_fragment();
|
||||
{
|
||||
@ -130,7 +158,7 @@ impl SvgLayerCanvas {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_anti_alias(true);
|
||||
paint.set_color(skia::Color::BLACK);
|
||||
draw_clip_geometry(cv, shape, tree, &paint);
|
||||
draw_clip_geometry(cv, shape, tree, &paint, self.silhouette_offset);
|
||||
}
|
||||
self.finish_clip_path_fragment(id, canvas);
|
||||
}
|
||||
@ -152,49 +180,242 @@ impl SvgLayerCanvas {
|
||||
));
|
||||
}
|
||||
|
||||
/// Registers a layer-blur `<filter>` and returns its id.
|
||||
/// Registers a composite effects `<filter>` (drop/inner shadows + optional
|
||||
/// layer blur) matching classic SVG filter order, and returns its id.
|
||||
///
|
||||
/// `sigma` is Skia/canvas stdDeviation (`radius_to_sigma(value * scale)`).
|
||||
/// Padding (±50%) avoids the default 10% objectBoundingBox clip on large blurs.
|
||||
pub(super) fn push_layer_blur_filter(&mut self, sigma: f32) -> String {
|
||||
let id = self.unique("blur");
|
||||
/// Order: transparent flood → drop shadows → SourceGraphic → inner shadows
|
||||
/// → layer blur. Shadow blur uses canvas sigma (`radius_to_sigma`); offsets
|
||||
/// and spread are scaled by the export scale.
|
||||
///
|
||||
/// When `blend_source_graphic` is false, the filter ends after the drop
|
||||
/// chain (used for container silhouette passes that must not re-emit
|
||||
/// content — content is drawn in a separate unfiltered group).
|
||||
pub(super) fn push_effects_filter(
|
||||
&mut self,
|
||||
drops: &[&Shadow],
|
||||
inners: &[&Shadow],
|
||||
layer_blur_sigma: Option<f32>,
|
||||
scale: f32,
|
||||
blend_source_graphic: bool,
|
||||
) -> String {
|
||||
let id = self.unique("fx");
|
||||
let mut body = String::new();
|
||||
body.push_str(r#"<feFlood flood-opacity="0" result="bg"/>"#);
|
||||
let mut prev = "bg".to_string();
|
||||
|
||||
for (i, shadow) in drops.iter().enumerate() {
|
||||
let result = format!("drop{i}");
|
||||
append_drop_shadow_primitives(
|
||||
&mut body, shadow, scale, &prev, &result, /* morph_spread */ true,
|
||||
);
|
||||
prev = result;
|
||||
}
|
||||
|
||||
if blend_source_graphic {
|
||||
body.push_str(&format!(
|
||||
r#"<feBlend mode="normal" in="SourceGraphic" in2="{prev}" result="shape"/>"#
|
||||
));
|
||||
prev = "shape".to_string();
|
||||
|
||||
for (i, shadow) in inners.iter().enumerate() {
|
||||
let result = format!("inner{i}");
|
||||
append_inner_shadow_primitives(&mut body, shadow, scale, &prev, &result);
|
||||
prev = result;
|
||||
}
|
||||
|
||||
if let Some(sigma) = layer_blur_sigma {
|
||||
body.push_str(&format!(
|
||||
r#"<feGaussianBlur in="{prev}" stdDeviation="{sigma}"/>"#
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.defs.push_str(&format!(
|
||||
concat!(
|
||||
"<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" ",
|
||||
"color-interpolation-filters=\"sRGB\">",
|
||||
"<feGaussianBlur stdDeviation=\"{sigma}\"/>",
|
||||
"</filter>"
|
||||
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
|
||||
"{body}</filter>"
|
||||
),
|
||||
id = id,
|
||||
sigma = sigma
|
||||
region = filter_region_attrs(self),
|
||||
body = body
|
||||
));
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
fn color_matrix_values(color: skia::Color) -> String {
|
||||
let r = f32::from(color.r()) / 255.0;
|
||||
let g = f32::from(color.g()) / 255.0;
|
||||
let b = f32::from(color.b()) / 255.0;
|
||||
let a = f32::from(color.a()) / 255.0;
|
||||
format!("0 0 0 0 {r} 0 0 0 0 {g} 0 0 0 0 {b} 0 0 0 {a} 0")
|
||||
}
|
||||
|
||||
/// Filter subregion covering the export page in user space.
|
||||
///
|
||||
/// Default SVG `objectBoundingBox` + `x/y=-50% width/height=200%` is a percent
|
||||
/// of the *shape* bbox. Shadow reach (offset + blur sigma + spread) is absolute
|
||||
/// pixels, so small/thin shapes crop the halo. Page bounds already include
|
||||
/// shadow/blur via `extrect`; sizing the filter to the page matches that.
|
||||
fn filter_region_attrs(builder: &SvgLayerCanvas) -> String {
|
||||
let w = builder.page_rect.width();
|
||||
let h = builder.page_rect.height();
|
||||
format!(r#"filterUnits="userSpaceOnUse" x="0" y="0" width="{w}" height="{h}""#)
|
||||
}
|
||||
|
||||
fn append_drop_shadow_primitives(
|
||||
body: &mut String,
|
||||
shadow: &Shadow,
|
||||
scale: f32,
|
||||
in2: &str,
|
||||
result: &str,
|
||||
morph_spread: bool,
|
||||
) {
|
||||
let sigma = radius_to_sigma(shadow.blur * scale);
|
||||
// `shadow.offset` must already be in the SVG filter user space (parent of
|
||||
// the transformed leaf). Callers map local design offsets through
|
||||
// `centered_transform().map_vector` for rotated/flipped leaves.
|
||||
let dx = shadow.offset.0 * scale;
|
||||
let dy = shadow.offset.1 * scale;
|
||||
let spread = shadow.spread * scale;
|
||||
let color = color_matrix_values(shadow.color);
|
||||
|
||||
body.push_str(
|
||||
r#"<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/>"#,
|
||||
);
|
||||
// Container silhouettes apply spread geometrically (GPU). Morphology on a
|
||||
// stroke ring expands both edges and makes border shadows look too thick.
|
||||
let after_morph = if morph_spread && spread > 0.0 {
|
||||
body.push_str(&format!(
|
||||
r#"<feMorphology in="alpha" operator="dilate" radius="{spread}" result="spread"/>"#
|
||||
));
|
||||
"spread"
|
||||
} else if morph_spread && spread < 0.0 {
|
||||
body.push_str(&format!(
|
||||
r#"<feMorphology in="alpha" operator="erode" radius="{}" result="spread"/>"#,
|
||||
-spread
|
||||
));
|
||||
"spread"
|
||||
} else {
|
||||
"alpha"
|
||||
};
|
||||
body.push_str(&format!(
|
||||
r#"<feOffset in="{after_morph}" dx="{dx}" dy="{dy}" result="off"/>"#
|
||||
));
|
||||
body.push_str(&format!(
|
||||
r#"<feGaussianBlur in="off" stdDeviation="{sigma}" result="blurred"/>"#
|
||||
));
|
||||
body.push_str(&format!(
|
||||
r#"<feColorMatrix in="blurred" type="matrix" values="{color}" result="colored"/>"#
|
||||
));
|
||||
body.push_str(&format!(
|
||||
r#"<feBlend mode="normal" in="colored" in2="{in2}" result="{result}"/>"#
|
||||
));
|
||||
}
|
||||
|
||||
fn append_inner_shadow_primitives(
|
||||
body: &mut String,
|
||||
shadow: &Shadow,
|
||||
scale: f32,
|
||||
in2: &str,
|
||||
result: &str,
|
||||
) {
|
||||
let sigma = radius_to_sigma(shadow.blur * scale);
|
||||
let dx = shadow.offset.0 * scale;
|
||||
let dy = shadow.offset.1 * scale;
|
||||
let spread = shadow.spread * scale;
|
||||
let color = color_matrix_values(shadow.color);
|
||||
|
||||
// Classic inner-shadow graph: hard alpha, optional erode for +spread,
|
||||
// offset+blur, subtract from hard alpha, tint, blend over prior result.
|
||||
body.push_str(
|
||||
r#"<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>"#,
|
||||
);
|
||||
let morph_in = if spread > 0.0 {
|
||||
body.push_str(&format!(
|
||||
r#"<feMorphology in="hardAlpha" operator="erode" radius="{spread}" result="spread"/>"#
|
||||
));
|
||||
"spread"
|
||||
} else {
|
||||
"hardAlpha"
|
||||
};
|
||||
body.push_str(&format!(
|
||||
r#"<feOffset in="{morph_in}" dx="{dx}" dy="{dy}" result="off"/>"#
|
||||
));
|
||||
body.push_str(&format!(
|
||||
r#"<feGaussianBlur in="off" stdDeviation="{sigma}" result="blurred"/>"#
|
||||
));
|
||||
body.push_str(
|
||||
r#"<feComposite in="blurred" in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" result="shadow"/>"#,
|
||||
);
|
||||
body.push_str(&format!(
|
||||
r#"<feColorMatrix in="shadow" type="matrix" values="{color}" result="colored"/>"#
|
||||
));
|
||||
body.push_str(&format!(
|
||||
r#"<feBlend mode="normal" in="colored" in2="{in2}" result="{result}"/>"#
|
||||
));
|
||||
}
|
||||
|
||||
/// Draws a clip geometry into `cv` (already set up with the page transform).
|
||||
fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) {
|
||||
///
|
||||
/// `silhouette_offset` is applied in local space (same as
|
||||
/// [`SvgLayerCanvas::silhouette_draw_matrix`]) so board `clip content` during a
|
||||
/// container drop pass tracks the shifted silhouette.
|
||||
fn draw_clip_geometry(
|
||||
cv: &skia::Canvas,
|
||||
shape: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
paint: &Paint,
|
||||
silhouette_offset: (f32, f32),
|
||||
) {
|
||||
if let Type::Group(_) = &shape.shape_type {
|
||||
for child_id in shape.children_ids_iter_forward(true) {
|
||||
if let Some(child) = tree.get(child_id) {
|
||||
draw_clip_geometry(cv, child, tree, paint);
|
||||
draw_clip_geometry(cv, child, tree, paint, silhouette_offset);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cv.save();
|
||||
cv.concat(&shape.centered_transform());
|
||||
let mut matrix = shape.centered_transform();
|
||||
let (dx, dy) = silhouette_offset;
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
matrix.pre_translate((dx, dy));
|
||||
}
|
||||
cv.concat(&matrix);
|
||||
draw_shape_geometry(cv, shape, paint);
|
||||
cv.restore();
|
||||
}
|
||||
|
||||
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
|
||||
/// blend mode, layer blur). Returns `None` when the shape needs no wrapper.
|
||||
/// Returns a shape whose `selrect` is expanded/shrunk by `outset` (design space).
|
||||
/// Used for GPU-matching geometric drop-shadow spread.
|
||||
pub(super) fn shape_with_selrect_outset(shape: &Shape, outset: f32) -> Shape {
|
||||
let mut out = shape.clone();
|
||||
if outset > 0.0 {
|
||||
out.selrect.outset((outset, outset));
|
||||
} else if outset < 0.0 {
|
||||
out.selrect.inset((-outset, -outset));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Builds the `<g>` attribute string for a leaf shape's composite effects
|
||||
/// (opacity, blend mode, drop/inner shadows, layer blur). Returns `None` when
|
||||
/// the shape needs no wrapper.
|
||||
///
|
||||
/// Layer blur is a native SVG `<filter>` (SkSVGDevice drops paint image-filters).
|
||||
/// Shadows still need dedicated re-emission in a later PR.
|
||||
/// Shadows and layer blur are native SVG `<filter>`s — `SkSVGDevice` drops the
|
||||
/// GPU `save_layer` image-filter path. When `suppress_filters` is set (parent
|
||||
/// drop-shadow silhouette pass), only opacity/blend are emitted.
|
||||
pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option<String> {
|
||||
wrapper_attrs(builder, element, EffectFilterMode::LeafComposite)
|
||||
}
|
||||
|
||||
/// Opacity / blend for a container, wrapping both drop silhouettes and content.
|
||||
///
|
||||
/// Matches GPU `render_shape_enter` (opacity save_layer before shadow composite)
|
||||
/// so container drop shadows inherit the board's opacity.
|
||||
pub(super) fn opacity_blend_attrs(element: &Shape) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
let opacity = element.opacity();
|
||||
@ -206,11 +427,168 @@ pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Opt
|
||||
parts.push(format!("style=\"mix-blend-mode:{css}\""));
|
||||
}
|
||||
|
||||
if let Some(blur) = element.visible_layer_blur() {
|
||||
// Match canvas `Shape::image_filter`: sigma from radius × export scale.
|
||||
let sigma = radius_to_sigma(blur.value * builder.scale);
|
||||
let id = builder.push_layer_blur_filter(sigma);
|
||||
parts.push(format!("filter=\"url(#{id})\""));
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner shadows / layer blur for a container's real content group.
|
||||
///
|
||||
/// Opacity and blend are applied by [`opacity_blend_attrs`] around silhouette +
|
||||
/// content. Drop shadows are a separate silhouette pass.
|
||||
pub(super) fn content_effect_attrs(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
element: &Shape,
|
||||
) -> Option<String> {
|
||||
wrapper_attrs(builder, element, EffectFilterMode::ContentWithoutDrops)
|
||||
}
|
||||
|
||||
/// Drop-shadow-only filter for one container silhouette pass.
|
||||
///
|
||||
/// Offset and spread are applied geometrically while drawing (local space,
|
||||
/// matching GPU). The filter only hardens alpha, blurs, and tints.
|
||||
pub(super) fn push_container_drop_filter(builder: &mut SvgLayerCanvas, shadow: &Shadow) -> String {
|
||||
let scale = builder.scale;
|
||||
let id = builder.unique("fx");
|
||||
let mut body = String::from(r#"<feFlood flood-opacity="0" result="bg"/>"#);
|
||||
// Zero offset: geometric `pre_translate` already moved the silhouette.
|
||||
let mut filter_shadow = *shadow;
|
||||
filter_shadow.offset = (0.0, 0.0);
|
||||
append_drop_shadow_primitives(
|
||||
&mut body,
|
||||
&filter_shadow,
|
||||
scale,
|
||||
"bg",
|
||||
"drop0",
|
||||
/* morph_spread */ false,
|
||||
);
|
||||
builder.defs.push_str(&format!(
|
||||
concat!(
|
||||
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
|
||||
"{body}</filter>"
|
||||
),
|
||||
id = id,
|
||||
region = filter_region_attrs(builder),
|
||||
body = body
|
||||
));
|
||||
id
|
||||
}
|
||||
|
||||
/// Dilates/erodes glyph alpha for text drawn inside a container drop silhouette.
|
||||
///
|
||||
/// Container drop filters omit `feMorphology` so stroke-ring silhouettes stay
|
||||
/// thin (morph fattens both edges). Text children still need spread: GPU paints
|
||||
/// them with `Shadow::get_drop_shadow_filter`, which wraps `drop_shadow_only`
|
||||
/// in `dilate(spread)` (thicken the already blurred shadow).
|
||||
///
|
||||
/// Approximation: we nest `feMorphology` on the glyph alpha *before* the
|
||||
/// parent container blur. That is morph-then-blur, not dilate-after-drop like
|
||||
/// Skia. Close enough for export parity; halo softness can differ slightly.
|
||||
pub(super) fn push_text_silhouette_spread_filter(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
spread: f32,
|
||||
) -> Option<String> {
|
||||
let radius = spread * builder.scale;
|
||||
if radius == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let id = builder.unique("txmorph");
|
||||
let (op, r) = if radius > 0.0 {
|
||||
("dilate", radius)
|
||||
} else {
|
||||
("erode", -radius)
|
||||
};
|
||||
builder.defs.push_str(&format!(
|
||||
concat!(
|
||||
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
|
||||
"<feMorphology in=\"SourceAlpha\" operator=\"{op}\" radius=\"{r}\" result=\"m\"/>",
|
||||
"<feFlood flood-color=\"#000000\" flood-opacity=\"1\" result=\"f\"/>",
|
||||
"<feComposite in=\"f\" in2=\"m\" operator=\"in\"/>",
|
||||
"</filter>"
|
||||
),
|
||||
id = id,
|
||||
region = filter_region_attrs(builder),
|
||||
op = op,
|
||||
r = r
|
||||
));
|
||||
Some(id)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum EffectFilterMode {
|
||||
/// Flood → drops → SourceGraphic → inners → blur (leaves).
|
||||
LeafComposite,
|
||||
/// Flood → SourceGraphic → inners → blur (container content; drops separate).
|
||||
ContentWithoutDrops,
|
||||
}
|
||||
|
||||
/// Maps a design-space shadow offset into SVG filter user space.
|
||||
///
|
||||
/// Leaf geometry is drawn with `centered_transform` (rotation / flip). GPU
|
||||
/// `drop_shadow_only` offsets in that local space, then the CTM maps it. SVG
|
||||
/// `feOffset` runs after the leaf is painted into the parent group, so the
|
||||
/// offset must be `map_vector` of the local offset or rotated shadows drift.
|
||||
fn shadow_with_user_space_offset(shape: &Shape, shadow: &Shadow) -> Shadow {
|
||||
let mut out = *shadow;
|
||||
let mapped = shape.centered_transform().map_vector(shadow.offset);
|
||||
out.offset = (mapped.x, mapped.y);
|
||||
out
|
||||
}
|
||||
|
||||
fn wrapper_attrs(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
element: &Shape,
|
||||
mode: EffectFilterMode,
|
||||
) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
// Leaves keep opacity/blend on the same wrapper as their filter. Containers
|
||||
// use [`opacity_blend_attrs`] outside silhouette + content instead.
|
||||
if matches!(mode, EffectFilterMode::LeafComposite) {
|
||||
let opacity = element.opacity();
|
||||
if opacity < 1.0 {
|
||||
parts.push(format!("opacity=\"{opacity}\""));
|
||||
}
|
||||
|
||||
if let Some(css) = blend_css(element.blend_mode().0) {
|
||||
parts.push(format!("style=\"mix-blend-mode:{css}\""));
|
||||
}
|
||||
}
|
||||
|
||||
if !builder.suppress_filters {
|
||||
let scale = builder.scale;
|
||||
let mapped_inners: Vec<Shadow> = element
|
||||
.inner_shadows_visible()
|
||||
.map(|s| shadow_with_user_space_offset(element, s))
|
||||
.collect();
|
||||
let inners: Vec<&Shadow> = mapped_inners.iter().collect();
|
||||
let layer_blur_sigma = element
|
||||
.visible_layer_blur()
|
||||
.map(|blur| radius_to_sigma(blur.value * scale));
|
||||
|
||||
match mode {
|
||||
EffectFilterMode::LeafComposite => {
|
||||
let mapped_drops: Vec<Shadow> = element
|
||||
.drop_shadows_visible()
|
||||
.map(|s| shadow_with_user_space_offset(element, s))
|
||||
.collect();
|
||||
let drops: Vec<&Shadow> = mapped_drops.iter().collect();
|
||||
if !drops.is_empty() || !inners.is_empty() || layer_blur_sigma.is_some() {
|
||||
let id =
|
||||
builder.push_effects_filter(&drops, &inners, layer_blur_sigma, scale, true);
|
||||
parts.push(format!("filter=\"url(#{id})\""));
|
||||
}
|
||||
}
|
||||
EffectFilterMode::ContentWithoutDrops => {
|
||||
if !inners.is_empty() || layer_blur_sigma.is_some() {
|
||||
let id =
|
||||
builder.push_effects_filter(&[], &inners, layer_blur_sigma, scale, true);
|
||||
parts.push(format!("filter=\"url(#{id})\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
use crate::error::Result;
|
||||
use crate::shapes::{Shape, Stroke};
|
||||
use crate::shapes::{Shadow, Shape};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::{effect_attrs, SvgLayerCanvas};
|
||||
use super::document::{
|
||||
content_effect_attrs, opacity_blend_attrs, push_container_drop_filter,
|
||||
shape_with_selrect_outset, SvgLayerCanvas,
|
||||
};
|
||||
use super::images::{emit_fills, emit_strokes};
|
||||
use super::render_tree;
|
||||
use crate::render::RenderResources;
|
||||
@ -14,11 +17,61 @@ pub(super) fn render_frame(
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let effects = effect_attrs(builder, element);
|
||||
// Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer
|
||||
// before the shadow composite).
|
||||
let composite = opacity_blend_attrs(element);
|
||||
if let Some(attrs) = &composite {
|
||||
builder.open_group(attrs);
|
||||
}
|
||||
|
||||
// One silhouette pass per drop: geometric offset+spread in local space
|
||||
// (GPU), filter only blurs/tints — keeps stroke-ring width and rotation.
|
||||
if !builder.suppress_filters {
|
||||
let drops: Vec<Shadow> = element.drop_shadows_visible().copied().collect();
|
||||
for shadow in &drops {
|
||||
let id = push_container_drop_filter(builder, shadow);
|
||||
builder.open_group(&format!("filter=\"url(#{id})\""));
|
||||
let prev_suppress = builder.suppress_filters;
|
||||
let prev_spread = builder.silhouette_spread;
|
||||
let prev_offset = builder.silhouette_offset;
|
||||
builder.suppress_filters = true;
|
||||
builder.silhouette_spread = shadow.spread;
|
||||
builder.silhouette_offset = shadow.offset;
|
||||
render_frame_body(builder, shared, element, tree, scale)?;
|
||||
builder.silhouette_offset = prev_offset;
|
||||
builder.silhouette_spread = prev_spread;
|
||||
builder.suppress_filters = prev_suppress;
|
||||
builder.close_group();
|
||||
}
|
||||
}
|
||||
|
||||
let effects = content_effect_attrs(builder, element);
|
||||
if let Some(attrs) = &effects {
|
||||
builder.open_group(attrs);
|
||||
}
|
||||
|
||||
// Content pass uses builder.silhouette_spread (0 after this frame's own
|
||||
// drop; still set when nested inside a parent silhouette — matches
|
||||
// render_leaf so nested board fills outset with inherited spread).
|
||||
render_frame_body(builder, shared, element, tree, scale)?;
|
||||
|
||||
if effects.is_some() {
|
||||
builder.close_group();
|
||||
}
|
||||
if composite.is_some() {
|
||||
builder.close_group();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_frame_body(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let spread = builder.silhouette_spread;
|
||||
let clipped = element.clip_content;
|
||||
if clipped {
|
||||
let clip_id = builder.unique("clip");
|
||||
@ -26,32 +79,43 @@ pub(super) fn render_frame(
|
||||
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
|
||||
}
|
||||
|
||||
// Frame background (frame space), with linked `<image>` for image fills.
|
||||
if !element.fills.is_empty() {
|
||||
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
|
||||
// Fills: GPU `fills::render` outsets the rect for drop-shadow spread.
|
||||
let mask = shape_with_selrect_outset(element, spread);
|
||||
let matrix = builder.silhouette_draw_matrix(element);
|
||||
emit_fills(
|
||||
builder,
|
||||
shared,
|
||||
&mask,
|
||||
&mask.fills,
|
||||
tree,
|
||||
scale,
|
||||
Some(matrix),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Children (absolute coords).
|
||||
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
|
||||
for child_id in &children {
|
||||
render_tree(builder, shared, child_id, tree, scale)?;
|
||||
}
|
||||
|
||||
// Close content clip before strokes. Outer (and half of center) strokes
|
||||
// extend past the frame bounds; keeping them under clip-path hides them.
|
||||
// Matches GPU: clipped-frame strokes render in exit without the frame clip.
|
||||
if clipped {
|
||||
builder.close_group();
|
||||
}
|
||||
|
||||
// Strokes over children (frame space), outside the content clip.
|
||||
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
|
||||
// Strokes: GPU ignores Rect/Frame stroke outset for drop spread. Use
|
||||
// emit_strokes (image + solid) under the silhouette/content CTM.
|
||||
let visible_strokes: Vec<_> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
|
||||
}
|
||||
|
||||
if effects.is_some() {
|
||||
builder.close_group();
|
||||
let matrix = builder.silhouette_draw_matrix(element);
|
||||
emit_strokes(
|
||||
builder,
|
||||
shared,
|
||||
element,
|
||||
&visible_strokes,
|
||||
scale,
|
||||
Some(matrix),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
use crate::error::Result;
|
||||
use crate::shapes::Shape;
|
||||
use crate::shapes::{Shadow, Shape};
|
||||
use crate::state::ShapesPoolRef;
|
||||
|
||||
use super::document::{effect_attrs, SvgLayerCanvas};
|
||||
use super::document::{
|
||||
content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, SvgLayerCanvas,
|
||||
};
|
||||
use super::render_tree;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
@ -13,7 +15,33 @@ pub(super) fn render_group(
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let effects = effect_attrs(builder, element);
|
||||
// Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer
|
||||
// before the shadow composite).
|
||||
let composite = opacity_blend_attrs(element);
|
||||
if let Some(attrs) = &composite {
|
||||
builder.open_group(attrs);
|
||||
}
|
||||
|
||||
if !builder.suppress_filters {
|
||||
let drops: Vec<Shadow> = element.drop_shadows_visible().copied().collect();
|
||||
for shadow in &drops {
|
||||
let id = push_container_drop_filter(builder, shadow);
|
||||
builder.open_group(&format!("filter=\"url(#{id})\""));
|
||||
let prev_suppress = builder.suppress_filters;
|
||||
let prev_spread = builder.silhouette_spread;
|
||||
let prev_offset = builder.silhouette_offset;
|
||||
builder.suppress_filters = true;
|
||||
builder.silhouette_spread = shadow.spread;
|
||||
builder.silhouette_offset = shadow.offset;
|
||||
render_group_children(builder, shared, element, tree, scale)?;
|
||||
builder.silhouette_offset = prev_offset;
|
||||
builder.silhouette_spread = prev_spread;
|
||||
builder.suppress_filters = prev_suppress;
|
||||
builder.close_group();
|
||||
}
|
||||
}
|
||||
|
||||
let effects = content_effect_attrs(builder, element);
|
||||
if let Some(attrs) = &effects {
|
||||
builder.open_group(attrs);
|
||||
}
|
||||
@ -22,14 +50,27 @@ pub(super) fn render_group(
|
||||
// will land in a later PR. For now we still emit the full child list
|
||||
// (including the mask shape as normal content) so basic group opacity
|
||||
// keeps working.
|
||||
|
||||
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
|
||||
for child_id in &children {
|
||||
render_tree(builder, shared, child_id, tree, scale)?;
|
||||
}
|
||||
render_group_children(builder, shared, element, tree, scale)?;
|
||||
|
||||
if effects.is_some() {
|
||||
builder.close_group();
|
||||
}
|
||||
if composite.is_some() {
|
||||
builder.close_group();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_group_children(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
element: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
) -> Result<()> {
|
||||
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
|
||||
for child_id in &children {
|
||||
render_tree(builder, shared, child_id, tree, scale)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -15,6 +15,9 @@ use crate::render::RenderResources;
|
||||
/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
|
||||
/// source URL become native linked `<image>` elements (see `store_image_url`);
|
||||
/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
|
||||
///
|
||||
/// `draw_matrix` is the leaf CTM (container drop silhouettes pass a local-offset
|
||||
/// matrix so linked images move with solid fills).
|
||||
pub(super) fn emit_fills(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
@ -22,19 +25,20 @@ pub(super) fn emit_fills(
|
||||
fills: &[Fill],
|
||||
tree: ShapesPoolRef,
|
||||
scale: f32,
|
||||
draw_matrix: Option<skia_safe::Matrix>,
|
||||
) -> Result<()> {
|
||||
if fills.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let matrix = draw_matrix.unwrap_or_else(|| shape.centered_transform());
|
||||
// fills[0] is the topmost layer; draw bottom → top.
|
||||
for fill in fills.iter().rev() {
|
||||
match fill {
|
||||
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
|
||||
emit_image_fill(builder, shared, shape, image_fill, tree)?;
|
||||
emit_image_fill(builder, shared, shape, image_fill, tree, matrix)?;
|
||||
}
|
||||
fill => {
|
||||
let matrix = shape.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
@ -58,16 +62,27 @@ fn emit_image_fill(
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
tree: ShapesPoolRef,
|
||||
draw_matrix: skia_safe::Matrix,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let clip_id = builder.unique("imgclip");
|
||||
// Clip uses builder.silhouette_offset (same space as draw_matrix during a
|
||||
// container drop silhouette pass).
|
||||
builder.push_clip_path(&clip_id, shape, tree);
|
||||
let href = xml_escape_attr(url);
|
||||
let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill);
|
||||
emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id);
|
||||
emit_linked_image_element(
|
||||
builder,
|
||||
shape,
|
||||
image_fill,
|
||||
dest_rect,
|
||||
&href,
|
||||
&clip_id,
|
||||
draw_matrix,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -76,23 +91,27 @@ fn emit_image_fill(
|
||||
/// Image strokes with a registered URL become a linked `<image>` clipped to the
|
||||
/// stroke silhouette (Skia drops the GPU save_layer + SrcIn path). Other strokes
|
||||
/// go through [`VectorRenderer`].
|
||||
///
|
||||
/// `draw_matrix` overrides the leaf CTM (container drop silhouettes pass a
|
||||
/// local-offset matrix; `None` uses `centered_transform`).
|
||||
pub(super) fn emit_strokes(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &mut RenderResources,
|
||||
shape: &Shape,
|
||||
strokes: &[&Stroke],
|
||||
scale: f32,
|
||||
draw_matrix: Option<skia_safe::Matrix>,
|
||||
) -> Result<()> {
|
||||
if strokes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let matrix = shape.centered_transform();
|
||||
let matrix = draw_matrix.unwrap_or_else(|| shape.centered_transform());
|
||||
// strokes[0] is topmost; draw bottom -> top.
|
||||
for stroke in strokes.iter().rev() {
|
||||
match &stroke.fill {
|
||||
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
|
||||
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale)?;
|
||||
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale, matrix)?;
|
||||
}
|
||||
_ => {
|
||||
let canvas = builder.canvas();
|
||||
@ -115,6 +134,7 @@ fn emit_image_stroke(
|
||||
stroke: &Stroke,
|
||||
image_fill: &ImageFill,
|
||||
scale: f32,
|
||||
draw_matrix: skia_safe::Matrix,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
@ -125,7 +145,7 @@ fn emit_image_stroke(
|
||||
{
|
||||
let cv: &skia_safe::Canvas = &canvas;
|
||||
cv.save();
|
||||
cv.concat(&shape.centered_transform());
|
||||
cv.concat(&draw_matrix);
|
||||
if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) {
|
||||
cv.restore();
|
||||
return Ok(());
|
||||
@ -136,7 +156,15 @@ fn emit_image_stroke(
|
||||
|
||||
let href = xml_escape_attr(url);
|
||||
let dest = image_stroke_dest_rect(shape, stroke);
|
||||
emit_linked_image_element(builder, shape, image_fill, dest, &href, &clip_id);
|
||||
emit_linked_image_element(
|
||||
builder,
|
||||
shape,
|
||||
image_fill,
|
||||
dest,
|
||||
&href,
|
||||
&clip_id,
|
||||
draw_matrix,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -160,13 +188,17 @@ fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect {
|
||||
}
|
||||
|
||||
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
|
||||
///
|
||||
/// `draw_matrix` is the shape-local CTM (must include container drop silhouette
|
||||
/// offset when drawing under a parent shadow filter).
|
||||
pub(super) fn emit_linked_image_element(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shape: &Shape,
|
||||
_shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
dest_rect: MathRect,
|
||||
href: &str,
|
||||
clip_id: &str,
|
||||
draw_matrix: skia_safe::Matrix,
|
||||
) {
|
||||
let opacity = image_fill.opacity() as f32 / 255.0;
|
||||
let preserve = if image_fill.keep_aspect_ratio() {
|
||||
@ -174,7 +206,7 @@ pub(super) fn emit_linked_image_element(
|
||||
} else {
|
||||
"none"
|
||||
};
|
||||
let transform = builder.page_shape_matrix_attr(shape);
|
||||
let transform = builder.page_draw_matrix_attr(&draw_matrix);
|
||||
|
||||
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
|
||||
String::new()
|
||||
|
||||
@ -60,8 +60,8 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
|
||||
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
|
||||
/// `<clipPath>`.
|
||||
///
|
||||
/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper.
|
||||
/// Shadows, masks, and text strokes still need dedicated SVG re-emission.
|
||||
/// Layer blur and drop/inner shadows are re-emitted as a native SVG `<filter>`
|
||||
/// wrapper. Masks and text strokes still need dedicated SVG re-emission.
|
||||
/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines;
|
||||
/// image-filled strokes use a linked `<image>` clipped to the stroke.
|
||||
pub fn render_to_svg(
|
||||
@ -135,7 +135,7 @@ use frames::render_frame;
|
||||
use groups::render_group;
|
||||
use text::render_text_fill;
|
||||
|
||||
use document::effect_attrs;
|
||||
use document::{effect_attrs, push_text_silhouette_spread_filter, shape_with_selrect_outset};
|
||||
use images::{emit_fills, emit_strokes};
|
||||
|
||||
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
|
||||
@ -193,40 +193,58 @@ fn render_leaf(
|
||||
}
|
||||
|
||||
{
|
||||
let spread = builder.silhouette_spread;
|
||||
// Spread outsets fills only (GPU). Rect/Frame strokes ignore outset.
|
||||
// Text keeps its selrect: GPU dilates shadow alpha, not layout bounds.
|
||||
let fill_shape = shape_with_selrect_outset(element, spread);
|
||||
// Always from the original element (not outset selrect) so the pivot
|
||||
// matches content; offset comes from the parent silhouette pass.
|
||||
let draw_matrix = builder.silhouette_draw_matrix(element);
|
||||
if matches!(element.shape_type, Type::Text(_)) {
|
||||
render_text_fill(builder, shared, element)?;
|
||||
// See `push_text_silhouette_spread_filter`: morph-before-blur approx
|
||||
// of GPU dilate(drop_shadow) for inherited container spread.
|
||||
let morph_id = push_text_silhouette_spread_filter(builder, spread);
|
||||
if let Some(id) = &morph_id {
|
||||
builder.open_group(&format!("filter=\"url(#{id})\""));
|
||||
}
|
||||
render_text_fill(builder, shared, element, draw_matrix)?;
|
||||
if morph_id.is_some() {
|
||||
builder.close_group();
|
||||
}
|
||||
} else if matches!(element.shape_type, Type::SVGRaw(_)) {
|
||||
let matrix = element.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
canvas.concat(&draw_matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_svg(element)?;
|
||||
canvas.restore();
|
||||
} else {
|
||||
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
|
||||
emit_fills(
|
||||
builder,
|
||||
shared,
|
||||
&fill_shape,
|
||||
&fill_shape.fills,
|
||||
tree,
|
||||
scale,
|
||||
Some(draw_matrix),
|
||||
)?;
|
||||
|
||||
let matrix = element.centered_transform();
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
renderer.draw_fill_inner_shadows(element)?;
|
||||
canvas.restore();
|
||||
// Drop/inner shadows are native SVG filters on the effects `<g>` —
|
||||
// do not draw them via Skia image-filters (SkSVGDevice drops them).
|
||||
|
||||
// Stroke geometry stays on the original selrect (GPU Rect/Frame
|
||||
// drop-shadow outset is a no-op for single strokes). Image strokes
|
||||
// go through emit_strokes (linked <image> + stroke clip).
|
||||
let visible_strokes: Vec<_> = element.visible_strokes().collect();
|
||||
if !visible_strokes.is_empty() {
|
||||
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
|
||||
if !element.has_fills() {
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
|
||||
for stroke in &visible_strokes {
|
||||
renderer.draw_stroke_inner_shadows(element, stroke)?;
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
emit_strokes(
|
||||
builder,
|
||||
shared,
|
||||
element,
|
||||
&visible_strokes,
|
||||
scale,
|
||||
Some(draw_matrix),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="144" viewBox="0 0 200 144"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="144" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.54901963 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><clipPath id="clip1" clipPathUnits="userSpaceOnUse">
|
||||
<rect transform="translate(0 24)" width="200" height="120"/>
|
||||
</clipPath><clipPath id="clip2" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="200" height="120"/>
|
||||
</clipPath></defs><g filter="url(#fx0)"><g clip-path="url(#clip1)">
|
||||
<rect fill="#F0F0F0" transform="translate(0 24)" width="200" height="120"/>
|
||||
<rect fill="#00C800" transform="translate(0 24)" x="20" y="20" width="160" height="80"/>
|
||||
</g></g><g clip-path="url(#clip2)">
|
||||
<rect fill="#F0F0F0" width="200" height="120"/>
|
||||
<rect fill="#00C800" x="20" y="20" width="160" height="80"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,12 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="237.64102" height="157.64102" viewBox="0 0 237.64102 157.64102"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="237.64102" height="157.64102" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="6.2735023" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.39215687 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="#F0F0F0" transform="translate(18.8205 18.8205)" width="200" height="120"/>
|
||||
<rect fill="#00C800" transform="translate(18.8205 18.8205)" x="20" y="20" width="80" height="60"/>
|
||||
</g>
|
||||
<rect fill="#F0F0F0" transform="translate(18.8205 10.8205)" width="200" height="120"/>
|
||||
<rect fill="#00C800" transform="translate(18.8205 10.8205)" x="20" y="20" width="80" height="60"/>
|
||||
</svg>
|
||||
@ -3,7 +3,7 @@ source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.9641016"/></filter></defs><g filter="url(#blur0)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="223.7846" height="123.78461" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feGaussianBlur in="shape" stdDeviation="3.9641016"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="blue" transform="translate(11.8923 11.8923)" width="90" height="100"/>
|
||||
<rect fill="#00C800" transform="translate(11.8923 11.8923)" x="110" width="90" height="100"/>
|
||||
</g></svg>
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="130.71281" height="110.712814" viewBox="0 0 130.71281 110.712814"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="130.71281" height="110.712814" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="4" dy="6" result="off"/><feGaussianBlur in="off" stdDeviation="5.118802" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="red" transform="translate(11.3564 9.35641)" width="100" height="80"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="123.78461" height="103.78461" viewBox="0 0 123.78461 103.78461"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="123.78461" height="103.78461" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/><feOffset in="hardAlpha" dx="2" dy="3" result="off"/><feGaussianBlur in="off" stdDeviation="3.9641016" result="blurred"/><feComposite in="blurred" in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" result="shadow"/><feColorMatrix in="shadow" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.7058824 0" result="colored"/><feBlend mode="normal" in="colored" in2="shape" result="inner0"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="#0080FF" transform="translate(9.8923 8.8923)" width="100" height="80"/>
|
||||
</g></svg>
|
||||
@ -3,6 +3,6 @@ source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6.2735023"/></filter></defs><g filter="url(#blur0)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="137.64102" height="117.64102" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feGaussianBlur in="shape" stdDeviation="6.2735023"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="red" transform="translate(18.8205 18.8205)" width="100" height="80"/>
|
||||
</g></svg>
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="232.4282" height="152.4282" viewBox="0 0 232.4282 152.4282"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="232.4282" height="152.4282" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
|
||||
<path transform="translate(20 20)" d="M200 0L0 0L0 120L200 120L200 0ZM5 5L5 115L195 115L195 5L5 5Z" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<path d="M200 0L0 0L0 120L200 120L200 0ZM5 5L5 115L195 115L195 5L5 5Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
@ -0,0 +1,10 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="148" viewBox="0 0 200 148"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="148" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g opacity="0.5"><g filter="url(#fx0)">
|
||||
<rect fill="#3D7BFF" transform="translate(0 28)" width="200" height="120"/>
|
||||
</g>
|
||||
<rect fill="#3D7BFF" width="200" height="120"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,18 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="332.4282" height="212.4282" viewBox="0 0 332.4282 212.4282"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><filter id="txmorph1" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" color-interpolation-filters="sRGB"><feMorphology in="SourceAlpha" operator="dilate" radius="4" result="m"/><feFlood flood-color="#000000" flood-opacity="1" result="f"/><feComposite in="f" in2="m" operator="in"/></filter><filter id="fx2" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feMorphology in="alpha" operator="dilate" radius="4" result="spread"/><feOffset in="spread" dx="10" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)"><g filter="url(#txmorph1)">
|
||||
<text transform="translate(20 20)" font-size="40" font-family="Source Sans Pro" x="40, 66.074219, 92.617188, 112.16797" y="76">
|
||||
HOLA
|
||||
</text>
|
||||
</g>
|
||||
<path transform="translate(20 20)" d="M300 0L0 0L0 180L300 180L300 0ZM5 5L5 175L295 175L295 5L5 5Z" fill-rule="evenodd"/>
|
||||
</g><g filter="url(#fx2)">
|
||||
<text font-size="40" font-family="Source Sans Pro" x="40, 66.074219, 92.617188, 112.16797" y="76">
|
||||
HOLA
|
||||
</text>
|
||||
</g>
|
||||
<path d="M300 0L0 0L0 180L300 180L300 0ZM5 5L5 175L295 175L295 5L5 5Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
@ -0,0 +1,14 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="330" height="160" viewBox="0 0 330 160"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="330" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><clipPath id="imgclip1" clipPathUnits="userSpaceOnUse">
|
||||
<rect transform="translate(40 40)" width="120" height="120"/>
|
||||
</clipPath><clipPath id="imgclip2" clipPathUnits="userSpaceOnUse">
|
||||
<rect width="120" height="120"/>
|
||||
</clipPath></defs><g filter="url(#fx0)"><g clip-path="url(#imgclip1)"><image href="images/test-fill.svg" x="0" y="0" width="120" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 40 40)"/></g>
|
||||
<rect fill="#3D7BFF" transform="translate(40 40)" x="170" width="120" height="120"/>
|
||||
</g><g clip-path="url(#imgclip2)"><image href="images/test-fill.svg" x="0" y="0" width="120" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g>
|
||||
<rect fill="#3D7BFF" x="170" width="120" height="120"/>
|
||||
</svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="241.56406" height="241.56406" viewBox="0 0 241.56406 241.56406"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="241.56406" height="241.56406" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="23.59401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.69803923 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="#3D7BFF" transform="translate(70.782 70.782)" width="100" height="100"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="110" viewBox="0 0 80 110"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="80" height="110" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="red" transform="matrix(0 1 -1 0 80 0)" width="100" height="80"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,13 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="280" height="160" viewBox="0 0 280 160"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="280" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.9843137 0 0 0 0 0.9529412 0 0 0 0 0 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><filter id="fx1" filterUnits="userSpaceOnUse" x="0" y="0" width="280" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="10" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="#EF5350" transform="translate(20 20)" x="40" y="30" width="80" height="40"/>
|
||||
<path transform="translate(20 20)" d="M260 0L0 0L0 140L260 140L260 0ZM5 5L5 135L255 135L255 5L5 5Z" fill-rule="evenodd"/>
|
||||
</g><g filter="url(#fx1)">
|
||||
<rect fill="#EF5350" x="40" y="30" width="80" height="40"/>
|
||||
</g>
|
||||
<path d="M260 0L0 0L0 140L260 140L260 0ZM5 5L5 135L255 135L255 5L5 5Z" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
@ -0,0 +1,12 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="388" height="268" viewBox="0 0 388 268"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="388" height="268" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="#3D7BFF" transform="translate(24 24)" x="-4" y="6" width="228" height="208"/>
|
||||
<rect fill="#00C800" transform="translate(24 24)" x="226" y="46" width="108" height="108"/>
|
||||
</g>
|
||||
<rect fill="#3D7BFF" transform="translate(24 24)" x="20" y="30" width="180" height="160"/>
|
||||
<rect fill="#00C800" transform="translate(24 24)" x="250" y="70" width="60" height="60"/>
|
||||
</svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
|
||||
<rect fill="red" width="100" height="80"/>
|
||||
</svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="25.892303" viewBox="0 0 223.7846 25.892303"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="223.7846" height="25.892303" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="12" result="off"/><feGaussianBlur in="off" stdDeviation="3.9641016" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.8 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="#3D7BFF" transform="translate(11.8923 0)" width="200" height="2"/>
|
||||
</g></svg>
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
source: src/render/svg/tests.rs
|
||||
expression: svg
|
||||
---
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32.856407" height="32.856407" viewBox="0 0 32.856407 32.856407"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="32.856407" height="32.856407" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="4" dy="4" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
|
||||
<rect fill="#3D7BFF" transform="translate(4.4282 4.4282)" width="16" height="16"/>
|
||||
</g></svg>
|
||||
@ -1,8 +1,8 @@
|
||||
use super::fixtures::*;
|
||||
|
||||
use crate::shapes::{
|
||||
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, SolidColor,
|
||||
StrokeCap, StrokeKind,
|
||||
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, Shadow,
|
||||
ShadowStyle, SolidColor, StrokeCap, StrokeKind,
|
||||
};
|
||||
use crate::state::ShapesPool;
|
||||
use crate::uuid::Uuid;
|
||||
@ -114,8 +114,8 @@ fn exports_leaf_layer_blur_as_fe_gaussian_blur() {
|
||||
"stdDeviation must match canvas radius_to_sigma(value * scale): {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("filter=\"url(#blur"),
|
||||
"shape group must reference the blur filter: {svg}"
|
||||
svg.contains("filter=\"url(#fx"),
|
||||
"shape group must reference the effects filter: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
@ -138,7 +138,7 @@ fn skips_hidden_layer_blur() {
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"),
|
||||
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#fx"),
|
||||
"hidden layer blur must not emit a filter: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
@ -185,9 +185,7 @@ fn exports_group_layer_blur_wrapping_children() {
|
||||
"group layer blur stdDeviation: {svg}"
|
||||
);
|
||||
// Filter wrapper must open before child geometry.
|
||||
let filter_pos = svg
|
||||
.find("filter=\"url(#blur")
|
||||
.expect("group filter wrapper");
|
||||
let filter_pos = svg.find("filter=\"url(#fx").expect("group filter wrapper");
|
||||
let child_pos = svg.find("fill=\"#").expect("child fill");
|
||||
assert!(
|
||||
filter_pos < child_pos,
|
||||
@ -196,6 +194,843 @@ fn exports_group_layer_blur_wrapping_children() {
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_leaf_drop_shadow_as_svg_filter() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
skia::Color::from_rgb(255, 0, 0),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
shape.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(128, 0, 0, 0),
|
||||
8.0,
|
||||
0.0,
|
||||
(4.0, 6.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
let expected_sigma = radius_to_sigma(8.0);
|
||||
assert!(
|
||||
svg.contains("feOffset") && svg.contains(r#"dx="4""#) && svg.contains(r#"dy="6""#),
|
||||
"drop shadow must offset: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")),
|
||||
"drop blur sigma must match canvas: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("filter=\"url(#fx") && svg.contains("SourceGraphic"),
|
||||
"drop shadow filter must blend SourceGraphic: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_drop_offset_follows_rotation_in_user_space() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
skia::Color::from_rgb(255, 0, 0),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
// 90° CCW: local (+10, 0) → user-space (0, 10).
|
||||
let (c, s) = (0.0_f32, 1.0_f32);
|
||||
shape.set_transform(c, s, -s, c, 0.0, 0.0);
|
||||
shape.set_rotation(90.0);
|
||||
shape.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(128, 56, 0, 238),
|
||||
0.0,
|
||||
0.0,
|
||||
(10.0, 0.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains(r#"dx="0""#) && svg.contains(r#"dy="10""#),
|
||||
"rotated leaf drop must map local offset into filter user space: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains(r#"dx="10""#),
|
||||
"must not keep unmapped local dx for rotated leaf: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
|
||||
// 16×16 + offset(4,4) blur 4: objectBoundingBox ±50% only leaves 8px margin,
|
||||
// but reach is ~|offset|+3σ ≈ 12px — corners crop unless the filter is
|
||||
// sized in userSpaceOnUse to the page (already padded via extrect).
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 16.0, 16.0),
|
||||
skia::Color::from_rgb(61, 123, 255),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
shape.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(153, 0, 0, 0),
|
||||
4.0,
|
||||
0.0,
|
||||
(4.0, 4.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert_filter_covers_page(&svg);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
|
||||
// 100×100 blur 40: ±50% of bbox = 50px, 3σ≈71px — halo crops with
|
||||
// objectBoundingBox percentages.
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 100.0),
|
||||
skia::Color::from_rgb(61, 123, 255),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
shape.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(178, 0, 0, 0),
|
||||
40.0,
|
||||
0.0,
|
||||
(0.0, 0.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert_filter_covers_page(&svg);
|
||||
let sigma = radius_to_sigma(40.0);
|
||||
assert!(
|
||||
svg.contains(&format!("stdDeviation=\"{sigma}\"")),
|
||||
"large blur sigma must remain in the filter: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
|
||||
// 200×2 + offset(0,12) blur 6: objectBoundingBox height is only 4px —
|
||||
// the shadow disappears. userSpaceOnUse page region keeps it.
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 200.0, 2.0),
|
||||
skia::Color::from_rgb(61, 123, 255),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
shape.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(204, 0, 0, 0),
|
||||
6.0,
|
||||
0.0,
|
||||
(0.0, 12.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert_filter_covers_page(&svg);
|
||||
assert!(
|
||||
svg.contains(r#"dy="12""#),
|
||||
"sliver drop must keep its offset: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_leaf_inner_shadow_as_svg_filter() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
skia::Color::from_rgb(0, 128, 255),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
shape.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(180, 0, 0, 0),
|
||||
6.0,
|
||||
0.0,
|
||||
(2.0, 3.0),
|
||||
ShadowStyle::Inner,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
svg.contains("feComposite") && svg.contains("hardAlpha"),
|
||||
"inner shadow must use classic composite graph: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("filter=\"url(#fx"),
|
||||
"inner shadow must wrap the shape: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_hidden_shadows() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let id = uid(1);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 100.0, 80.0),
|
||||
skia::Color::from_rgb(255, 0, 0),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&id).unwrap();
|
||||
shape.add_shadow(Shadow::new(
|
||||
skia::Color::BLACK,
|
||||
8.0,
|
||||
0.0,
|
||||
(4.0, 4.0),
|
||||
ShadowStyle::Drop,
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
!svg.contains("feOffset") && !svg.contains("filter=\"url(#fx"),
|
||||
"hidden shadow must not emit a filter: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_frame_drop_shadow_wrapping_children() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let frame_id = uid(1);
|
||||
let child = uid(2);
|
||||
add_frame(
|
||||
&mut pool,
|
||||
frame_id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 200.0, 120.0),
|
||||
skia::Color::from_rgb(240, 240, 240),
|
||||
false,
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(100, 0, 0, 0),
|
||||
10.0,
|
||||
0.0,
|
||||
(0.0, 8.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
child,
|
||||
frame_id,
|
||||
(20.0, 20.0, 100.0, 80.0),
|
||||
skia::Color::from_rgb(0, 200, 0),
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.add_child(child);
|
||||
}
|
||||
|
||||
let svg = render(&pool, frame_id);
|
||||
assert!(
|
||||
svg.contains("filter=\"url(#fx"),
|
||||
"frame drop shadow must emit a filter: {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("SourceGraphic"),
|
||||
"container drop filter must be shadow-only (no SourceGraphic): {svg}"
|
||||
);
|
||||
// Silhouette under the filter, then real content without nesting the filter.
|
||||
let filter_pos = svg.find("filter=\"url(#fx").expect("frame filter");
|
||||
let child_pos = svg.find("fill=\"#").expect("child fill");
|
||||
assert!(
|
||||
filter_pos < child_pos,
|
||||
"frame shadow silhouette must precede content: {svg}"
|
||||
);
|
||||
let fill_count = svg.matches("fill=\"#").count();
|
||||
assert!(
|
||||
fill_count >= 2,
|
||||
"silhouette + content must both draw fills: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() {
|
||||
// clip=ON + drop offset: silhouette fills/children move with
|
||||
// silhouette_draw_matrix, so the board clipPath must move too — otherwise
|
||||
// the unshifted clip truncates the shadow (F1a / show-content=false).
|
||||
let mut pool = ShapesPool::new();
|
||||
let frame_id = uid(1);
|
||||
let child = uid(2);
|
||||
add_frame(
|
||||
&mut pool,
|
||||
frame_id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 200.0, 120.0),
|
||||
skia::Color::from_rgb(240, 240, 240),
|
||||
true,
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(140, 0, 0, 0),
|
||||
0.0,
|
||||
0.0,
|
||||
(0.0, 24.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
child,
|
||||
frame_id,
|
||||
(20.0, 20.0, 180.0, 100.0),
|
||||
skia::Color::from_rgb(0, 200, 0),
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.add_child(child);
|
||||
}
|
||||
|
||||
let svg = render(&pool, frame_id);
|
||||
assert!(
|
||||
svg.contains("filter=\"url(#fx"),
|
||||
"clipped frame drop shadow must emit a filter: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.matches("<clipPath").count() >= 2,
|
||||
"silhouette and content each need a clipPath: {svg}"
|
||||
);
|
||||
|
||||
let filter_open = svg.find("filter=\"url(#fx").expect("frame filter");
|
||||
let filter_close = svg[filter_open..]
|
||||
.find("</g>")
|
||||
.map(|i| filter_open + i)
|
||||
.expect("silhouette group close");
|
||||
let silhouette = &svg[filter_open..=filter_close];
|
||||
let clip_ref = silhouette
|
||||
.find("clip-path=\"url(#")
|
||||
.and_then(|i| {
|
||||
let start = i + "clip-path=\"url(#".len();
|
||||
let end = silhouette[start..].find(')')?;
|
||||
Some(&silhouette[start..start + end])
|
||||
})
|
||||
.expect("silhouette must reference a clipPath");
|
||||
|
||||
let clip_def_start = svg
|
||||
.find(&format!("<clipPath id=\"{clip_ref}\""))
|
||||
.expect("silhouette clipPath def");
|
||||
let clip_def_end = svg[clip_def_start..]
|
||||
.find("</clipPath>")
|
||||
.map(|i| clip_def_start + i)
|
||||
.expect("clipPath close");
|
||||
let clip_geom = &svg[clip_def_start..clip_def_end];
|
||||
|
||||
// Content clip (second clipPath) stays unshifted; silhouette clip must
|
||||
// carry the local drop offset (0, 24) like silhouette fills.
|
||||
assert!(
|
||||
clip_geom.contains("translate(") && clip_geom.contains(" 24"),
|
||||
"silhouette clipPath must follow drop offset (0,24): {clip_geom}\nfull: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_drop_silhouette_shifts_image_fill_children() {
|
||||
// Container drop silhouettes must offset linked <image> fills the same way
|
||||
// as solid fills (F3). Clip already follows silhouette_offset; the image
|
||||
// CTM must use draw_matrix too.
|
||||
let mut pool = ShapesPool::new();
|
||||
let group_id = uid(1);
|
||||
let image_child = uid(2);
|
||||
let solid_child = uid(3);
|
||||
let image_id = uid(42);
|
||||
|
||||
add_image_rect(
|
||||
&mut pool,
|
||||
image_child,
|
||||
group_id,
|
||||
(0.0, 0.0, 120.0, 120.0),
|
||||
image_id,
|
||||
true,
|
||||
255,
|
||||
);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
solid_child,
|
||||
group_id,
|
||||
(170.0, 0.0, 290.0, 120.0),
|
||||
skia::Color::from_rgb(61, 123, 255),
|
||||
);
|
||||
add_group(
|
||||
&mut pool,
|
||||
group_id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 290.0, 120.0),
|
||||
&[image_child, solid_child],
|
||||
);
|
||||
{
|
||||
let group = pool.get_mut(&group_id).unwrap();
|
||||
group.add_shadow(Shadow::new(
|
||||
skia::Color::from_rgb(229, 16, 35),
|
||||
0.0,
|
||||
0.0,
|
||||
(40.0, 40.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render_with(&pool, group_id, |resources| {
|
||||
resources
|
||||
.images
|
||||
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
|
||||
});
|
||||
|
||||
assert!(
|
||||
svg.contains("filter=\"url(#fx"),
|
||||
"group drop shadow must emit a filter: {svg}"
|
||||
);
|
||||
|
||||
let filter_open = svg.find("filter=\"url(#fx").expect("group filter");
|
||||
let silhouette = &svg[filter_open..];
|
||||
// Content pass repeats the image without the silhouette offset matrix.
|
||||
let content_image = silhouette
|
||||
.match_indices("<image")
|
||||
.nth(1)
|
||||
.map(|(i, _)| filter_open + i);
|
||||
let silhouette = match content_image {
|
||||
Some(end) => &svg[filter_open..end],
|
||||
None => silhouette,
|
||||
};
|
||||
|
||||
assert!(
|
||||
silhouette.contains("<image") && silhouette.contains(TEST_IMAGE_URL),
|
||||
"silhouette must include the image-fill child: {silhouette}\nfull: {svg}"
|
||||
);
|
||||
assert!(
|
||||
silhouette.contains("fill=\"#3D7BFF\"") || silhouette.contains("fill=\"#3d7bff\""),
|
||||
"silhouette must include the solid child: {silhouette}\nfull: {svg}"
|
||||
);
|
||||
assert!(
|
||||
silhouette.contains(r#"translate(40 40)"#),
|
||||
"solid silhouette child must apply group drop offset: {silhouette}\nfull: {svg}"
|
||||
);
|
||||
assert!(
|
||||
silhouette.contains("matrix(1 0 0 1 40 40)"),
|
||||
"image silhouette child must apply the same local offset via draw_matrix: {silhouette}\nfull: {svg}"
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_frame_fill_outsets_under_parent_drop_spread() {
|
||||
// Outer board: no fill, drop spread 24. Nested board fill must outset by
|
||||
// the inherited silhouette_spread (F5) — same as leaf children via
|
||||
// render_leaf. Hardcoding 0.0 on the nested frame content pass left the
|
||||
// nested board hugging its true edge while the leaf got the red ring.
|
||||
let mut pool = ShapesPool::new();
|
||||
let outer = uid(1);
|
||||
let nested = uid(2);
|
||||
let leaf = uid(3);
|
||||
|
||||
add_frame(
|
||||
&mut pool,
|
||||
outer,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 340.0, 220.0),
|
||||
skia::Color::TRANSPARENT,
|
||||
false,
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&outer).unwrap();
|
||||
frame.clear_fills();
|
||||
frame.add_shadow(Shadow::new(
|
||||
skia::Color::from_rgb(229, 16, 35),
|
||||
0.0,
|
||||
24.0,
|
||||
(0.0, 0.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
add_frame(
|
||||
&mut pool,
|
||||
nested,
|
||||
outer,
|
||||
(20.0, 30.0, 200.0, 190.0),
|
||||
skia::Color::from_rgb(61, 123, 255),
|
||||
false,
|
||||
);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
leaf,
|
||||
outer,
|
||||
(250.0, 70.0, 310.0, 130.0),
|
||||
skia::Color::from_rgb(0, 200, 0),
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&outer).unwrap();
|
||||
frame.add_child(nested);
|
||||
frame.add_child(leaf);
|
||||
}
|
||||
|
||||
let svg = render(&pool, outer);
|
||||
assert!(
|
||||
svg.contains("filter=\"url(#fx"),
|
||||
"outer drop shadow must emit a filter: {svg}"
|
||||
);
|
||||
|
||||
let filter_open = svg.find("filter=\"url(#fx").expect("outer filter");
|
||||
let after = &svg[filter_open..];
|
||||
// Content pass redraws the nested board at true size (180×160); silhouette
|
||||
// must use the spread-outset size (180+48)×(160+48).
|
||||
let silhouette_end = after
|
||||
.find("width=\"180\"")
|
||||
.map(|i| filter_open + i)
|
||||
.expect("content nested board at true size");
|
||||
let silhouette = &svg[filter_open..silhouette_end];
|
||||
|
||||
assert!(
|
||||
silhouette.contains("width=\"228\"") && silhouette.contains("height=\"208\""),
|
||||
"nested board fill must outset by parent spread 24 (180+48, 160+48): {silhouette}\nfull: {svg}"
|
||||
);
|
||||
assert!(
|
||||
silhouette.contains("width=\"108\"") && silhouette.contains("height=\"108\""),
|
||||
"leaf fill must also outset by parent spread 24 (60+48): {silhouette}\nfull: {svg}"
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_drop_silhouette_inherits_board_opacity() {
|
||||
// GPU opens the opacity save_layer before the shadow composite, so a board
|
||||
// at opacity 0.5 casts a half-strength drop. The silhouette filter group
|
||||
// must sit inside the opacity wrapper (F6a), not beside it.
|
||||
let mut pool = ShapesPool::new();
|
||||
let frame_id = uid(1);
|
||||
add_frame(
|
||||
&mut pool,
|
||||
frame_id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 200.0, 120.0),
|
||||
skia::Color::from_rgb(61, 123, 255),
|
||||
false,
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.set_opacity(0.5);
|
||||
frame.add_shadow(Shadow::new(
|
||||
skia::Color::BLACK,
|
||||
0.0,
|
||||
0.0,
|
||||
(0.0, 28.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, frame_id);
|
||||
let opacity_pos = svg.find(r#"opacity="0.5""#).expect("board opacity wrapper");
|
||||
let filter_pos = svg
|
||||
.find("filter=\"url(#fx")
|
||||
.expect("drop silhouette filter");
|
||||
assert!(
|
||||
opacity_pos < filter_pos,
|
||||
"opacity must wrap the drop silhouette (GPU order): {svg}"
|
||||
);
|
||||
|
||||
// Silhouette group is nested inside the opacity group — closing opacity
|
||||
// after the filter group means the shadow is attenuated.
|
||||
let after_opacity = &svg[opacity_pos..];
|
||||
assert!(
|
||||
after_opacity.contains("filter=\"url(#fx"),
|
||||
"drop silhouette must be inside the opacity wrapper: {svg}"
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_child_drop_shadow_is_not_refiltered_by_frame() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let frame_id = uid(1);
|
||||
let child = uid(2);
|
||||
add_frame(
|
||||
&mut pool,
|
||||
frame_id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 260.0, 140.0),
|
||||
skia::Color::TRANSPARENT,
|
||||
false,
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.clear_fills();
|
||||
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
|
||||
frame.add_shadow(Shadow::new(
|
||||
skia::Color::from_rgb(251, 243, 0),
|
||||
0.0,
|
||||
0.0,
|
||||
(20.0, 20.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
child,
|
||||
frame_id,
|
||||
(40.0, 30.0, 120.0, 70.0),
|
||||
skia::Color::from_rgb(239, 83, 80),
|
||||
);
|
||||
{
|
||||
let shape = pool.get_mut(&child).unwrap();
|
||||
shape.add_shadow(Shadow::new(
|
||||
skia::Color::from_rgb(56, 0, 238),
|
||||
0.0,
|
||||
0.0,
|
||||
(10.0, 10.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.add_child(child);
|
||||
}
|
||||
|
||||
let svg = render(&pool, frame_id);
|
||||
let filter_attrs: Vec<_> = svg.match_indices("filter=\"url(#fx").collect();
|
||||
assert_eq!(
|
||||
filter_attrs.len(),
|
||||
2,
|
||||
"expect frame silhouette filter + child content filter only: {svg}"
|
||||
);
|
||||
// Child's filter must not sit inside the frame's filtered group.
|
||||
let frame_filter_open = svg.find("<g filter=\"url(#fx").expect("frame filter group");
|
||||
let frame_filter_close = svg[frame_filter_open..]
|
||||
.find("</g>")
|
||||
.map(|i| frame_filter_open + i)
|
||||
.expect("close frame filter group");
|
||||
let child_filter = svg.rfind("<g filter=\"url(#fx").expect("child filter");
|
||||
assert!(
|
||||
child_filter > frame_filter_close,
|
||||
"child drop filter must be outside frame drop group to avoid shadow-of-shadow: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(r#"dx="10""#),
|
||||
"child leaf drop must keep filter offset: {svg}"
|
||||
);
|
||||
// Frame container drops apply offset geometrically (filter dx=0).
|
||||
assert!(
|
||||
svg.contains(r#"dx="0""#) || svg.matches(r#"dx=""#).count() >= 1,
|
||||
"frame drop filter must not re-offset in user space: {svg}"
|
||||
);
|
||||
// Stroke-ring silhouette (border shadow), not a solid board fill.
|
||||
let silhouette = &svg[frame_filter_open..=frame_filter_close];
|
||||
assert!(
|
||||
silhouette.contains("fill-rule=\"evenodd\"") || silhouette.contains("<path"),
|
||||
"frame stroke must be in the drop-shadow silhouette: {silhouette}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_drop_silhouette_offsets_child_text() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let frame_id = uid(1);
|
||||
let text_id = uid(2);
|
||||
add_frame(
|
||||
&mut pool,
|
||||
frame_id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 300.0, 180.0),
|
||||
skia::Color::TRANSPARENT,
|
||||
false,
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.clear_fills();
|
||||
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
|
||||
frame.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(128, 229, 16, 35),
|
||||
4.0,
|
||||
4.0,
|
||||
(20.0, 20.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
add_solid_text(
|
||||
&mut pool,
|
||||
text_id,
|
||||
(40.0, 40.0, 200.0, 120.0),
|
||||
"HOLA",
|
||||
40.0,
|
||||
skia::Color::BLACK,
|
||||
);
|
||||
{
|
||||
let text = pool.get_mut(&text_id).unwrap();
|
||||
text.set_parent(frame_id);
|
||||
text.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(128, 56, 0, 238),
|
||||
4.0,
|
||||
4.0,
|
||||
(10.0, 10.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.add_child(text_id);
|
||||
}
|
||||
|
||||
let svg = render(&pool, frame_id);
|
||||
let frame_filter_open = svg.find("<g filter=\"url(#fx").expect("frame filter group");
|
||||
let frame_filter_close = svg[frame_filter_open..]
|
||||
.find("</g>")
|
||||
.map(|i| frame_filter_open + i)
|
||||
.expect("close frame filter group");
|
||||
let silhouette = &svg[frame_filter_open..=frame_filter_close];
|
||||
assert!(
|
||||
silhouette.contains("<text"),
|
||||
"frame drop silhouette must include child text: {silhouette}"
|
||||
);
|
||||
assert!(
|
||||
silhouette.contains(r#"transform="translate(20 20)""#),
|
||||
"silhouette text must apply frame drop offset in local space: {silhouette}"
|
||||
);
|
||||
assert!(
|
||||
silhouette.contains("feMorphology") || svg.contains("txmorph"),
|
||||
"silhouette text must dilate for frame drop spread: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(r#"operator="dilate""#) && svg.contains(r#"radius="4""#),
|
||||
"text silhouette spread must dilate by frame shadow spread: {svg}"
|
||||
);
|
||||
// Content text (outside silhouette) must stay unshifted.
|
||||
let content = &svg[frame_filter_close..];
|
||||
let content_text_start = content.find("<text").expect("content text");
|
||||
let content_text_end = content[content_text_start..]
|
||||
.find("</text>")
|
||||
.map(|i| content_text_start + i)
|
||||
.expect("content text end");
|
||||
let content_text = &content[content_text_start..=content_text_end];
|
||||
assert!(
|
||||
!content_text.contains("translate(20 20)"),
|
||||
"content text must not carry silhouette offset: {content_text}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_less_frame_drop_shadow_ignores_stroke_spread_outset() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let frame_id = uid(1);
|
||||
add_frame(
|
||||
&mut pool,
|
||||
frame_id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 200.0, 120.0),
|
||||
skia::Color::TRANSPARENT,
|
||||
false,
|
||||
);
|
||||
{
|
||||
let frame = pool.get_mut(&frame_id).unwrap();
|
||||
frame.clear_fills();
|
||||
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
|
||||
frame.add_shadow(Shadow::new(
|
||||
skia::Color::from_argb(128, 229, 16, 35),
|
||||
4.0,
|
||||
4.0,
|
||||
(20.0, 20.0),
|
||||
ShadowStyle::Drop,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
let svg = render(&pool, frame_id);
|
||||
assert!(
|
||||
!svg.contains("feMorphology"),
|
||||
"container drop must not use feMorphology: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(r#"dx="0""#) && svg.contains("feGaussianBlur"),
|
||||
"container drop filter must blur only (offset is geometric): {svg}"
|
||||
);
|
||||
let filter_open = svg.find("<g filter=\"url(#fx").expect("drop group");
|
||||
let filter_close = svg[filter_open..]
|
||||
.find("</g>")
|
||||
.map(|i| filter_open + i)
|
||||
.expect("close drop group");
|
||||
let silhouette = &svg[filter_open..=filter_close];
|
||||
assert!(
|
||||
silhouette.contains("fill-rule=\"evenodd\""),
|
||||
"silhouette must stay a stroke ring: {silhouette}"
|
||||
);
|
||||
// GPU ignores Rect/Frame stroke outset — ring must match content selrect
|
||||
// (200×120), not an expanded 208×128 path.
|
||||
assert!(
|
||||
silhouette.contains("M200 ") || silhouette.contains("L200 "),
|
||||
"stroke silhouette must not grow with spread: {silhouette}"
|
||||
);
|
||||
assert!(
|
||||
!silhouette.contains("M204 ") && !silhouette.contains("L204 "),
|
||||
"spread must not outset frame stroke geometry: {silhouette}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_a_group_with_two_rects_and_group_opacity() {
|
||||
let mut pool = ShapesPool::new();
|
||||
@ -1369,6 +2204,32 @@ fn exports_image_fill_on_frame() {
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
fn assert_filter_covers_page(svg: &str) {
|
||||
let width = svg
|
||||
.split_once("width=\"")
|
||||
.and_then(|(_, rest)| rest.split_once('"').map(|(w, _)| w))
|
||||
.expect("svg width");
|
||||
let height = svg
|
||||
.split_once("height=\"")
|
||||
.and_then(|(_, rest)| rest.split_once('"').map(|(h, _)| h))
|
||||
.expect("svg height");
|
||||
assert!(
|
||||
svg.contains("filterUnits=\"userSpaceOnUse\""),
|
||||
"shadow/blur filters must use userSpaceOnUse (not objectBoundingBox %): {svg}"
|
||||
);
|
||||
assert!(
|
||||
!svg.contains("x=\"-50%\"") && !svg.contains("width=\"200%\""),
|
||||
"must not size filters as a percent of the shape bbox: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(&format!("width=\"{width}\""))
|
||||
&& svg.contains(&format!("height=\"{height}\""))
|
||||
&& svg.contains(r#"x="0""#)
|
||||
&& svg.contains(r#"y="0""#),
|
||||
"filter region must cover the export page ({width}×{height}): {svg}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_linked_image_stroke(svg: &str) {
|
||||
assert!(
|
||||
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
|
||||
|
||||
@ -13,10 +13,16 @@ use crate::render::RenderResources;
|
||||
///
|
||||
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
|
||||
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
|
||||
///
|
||||
/// `draw_matrix` is the leaf CTM (normally `centered_transform`). During a
|
||||
/// parent drop-shadow silhouette pass it must include the geometric offset
|
||||
/// (`silhouette_draw_matrix`); using only `centered_transform` leaves child
|
||||
/// text unshifted while strokes/fills move.
|
||||
pub(super) fn render_text_fill(
|
||||
builder: &mut SvgLayerCanvas,
|
||||
shared: &RenderResources,
|
||||
element: &Shape,
|
||||
draw_matrix: skia_safe::Matrix,
|
||||
) -> Result<()> {
|
||||
let text_content = element.get_text_content();
|
||||
let text_content = text_content.new_bounds(element.selrect());
|
||||
@ -25,14 +31,12 @@ pub(super) fn render_text_fill(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let matrix = element.centered_transform();
|
||||
|
||||
for layer in 0..max_layers {
|
||||
let linked = linked_image_fills_at_layer(&text_content, layer, shared);
|
||||
let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect();
|
||||
|
||||
for image_fill in &linked {
|
||||
emit_text_image_fill(builder, shared, element, image_fill, layer)?;
|
||||
emit_text_image_fill(builder, shared, element, image_fill, layer, draw_matrix)?;
|
||||
}
|
||||
|
||||
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
|
||||
@ -44,7 +48,7 @@ pub(super) fn render_text_fill(
|
||||
};
|
||||
let canvas = builder.canvas();
|
||||
canvas.save();
|
||||
canvas.concat(&matrix);
|
||||
canvas.concat(&draw_matrix);
|
||||
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
|
||||
canvas.restore();
|
||||
}
|
||||
@ -96,6 +100,7 @@ fn emit_text_image_fill(
|
||||
shape: &Shape,
|
||||
image_fill: &ImageFill,
|
||||
layer: usize,
|
||||
draw_matrix: skia_safe::Matrix,
|
||||
) -> Result<()> {
|
||||
let Some(url) = shared.images.source_url(&image_fill.id()) else {
|
||||
return Ok(());
|
||||
@ -110,13 +115,21 @@ fn emit_text_image_fill(
|
||||
{
|
||||
let cv: &skia_safe::Canvas = &canvas;
|
||||
cv.save();
|
||||
cv.concat(&shape.centered_transform());
|
||||
cv.concat(&draw_matrix);
|
||||
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
|
||||
cv.restore();
|
||||
}
|
||||
builder.finish_clip_path_fragment(&clip_id, canvas);
|
||||
|
||||
let href = xml_escape_attr(url);
|
||||
emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id);
|
||||
emit_linked_image_element(
|
||||
builder,
|
||||
shape,
|
||||
image_fill,
|
||||
shape.selrect(),
|
||||
&href,
|
||||
&clip_id,
|
||||
draw_matrix,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user