Fix clipping masks, click targets, and bounds when rendering Vector[] and flattened graphic wrappers (#4429)

* Honor clipping masks when rendering Vector[] lists, not just Graphic[] groups

* Keep per-layer click targets when a Vector[] round-trips through a Graphic[] wrapper

* Skip the rebuild and the merged-layers snapshot when flattening a lone anonymous graphic wrapper

* Fix a group's bounds excluding its raster children and stretching to the origin

* Fix SVG clipping masks landing in the wrong space when sibling layers differ in transform
This commit is contained in:
Keavon Chambers
2026-08-11 13:31:48 -07:00
committed by GitHub
parent 8e23d3509b
commit 9a129ecf5c
3 changed files with 434 additions and 281 deletions

View File

@@ -111,9 +111,27 @@ impl From<List<String>> for Graphic {
}
}
/// Whether the list is a single leaf item carrying nothing to compose onto its contents, so flattening it
/// collapses no structure and rebuilding or snapshotting the result would be busywork.
pub fn is_lone_anonymous_leaf(content: &List<Graphic>) -> bool {
content.len() == 1
&& !matches!(content.element(0), Some(Graphic::Graphic(_)))
&& content.attribute::<DAffine2>(ATTR_TRANSFORM, 0).is_none()
&& content.attribute::<f64>(ATTR_OPACITY, 0).is_none()
&& content.attribute::<f64>(ATTR_OPACITY_FILL, 0).is_none()
&& content.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).is_none()
}
/// Deeply flattens a `List<Graphic>`, collecting only elements matching a specific variant (extracted by `extract_variant`)
/// and discarding all other non-matching content. Recursion through `Graphic::Graphic` sub-`List`s composes transforms and opacity.
fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) -> List<T> {
// Its list is already the flat answer, so hand it back rather than rebuilding it item by item
if is_lone_anonymous_leaf(&content) {
let Some(item) = content.into_iter().next() else { return List::new() };
return extract_variant(item.into_element()).unwrap_or_default();
}
fn flatten_recursive<T>(output: &mut List<T>, current_graphic_list: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) {
for current_graphic_item in current_graphic_list.into_iter() {
// Whether the parent carries each attribute: a structural fact (column presence), never a value comparison.
@@ -133,6 +151,12 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
// Compose the parent's transform/opacity/fill onto each child, but only for attributes the parent carries.
// A child lacking one is padded with the composition identity (`1.` for opacity/fill, identity for transform), so composing through it is a no-op.
Graphic::Graphic(mut sub_list) => {
// A group's first child has no preceding sibling, so its clipping flag is inert until splicing
// hands it the group's own predecessor. Clear it (keeping the column) to stay clip-neutral.
if sub_list.attribute::<bool>(ATTR_CLIPPING_MASK, 0).is_some() {
sub_list.set_attribute(ATTR_CLIPPING_MASK, 0, false);
}
if parent_has_transform {
for v in sub_list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
*v = current_transform * *v;
@@ -304,14 +328,9 @@ impl IntoGraphicList for List<Graphic> {
impl IntoGraphicList for List<Vector> {
fn into_graphic_list(self) -> List<Graphic> {
// Propagate the `editor:layer_path` column (if present) from item 0 onto the wrapper Graphic item so a
// subsequent `flatten_graphic_list` doesn't drop the inner Vector's layer stamp
let layer_path = self.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).cloned();
let mut graphic_list = List::new_from_element(Graphic::Vector(self));
if let Some(layer_path) = layer_path {
graphic_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
}
graphic_list
// A synthetic container, not a real group layer, so it carries no `editor:layer_path` that would
// overwrite the inner items' own stamps when flattened back out
List::new_from_element(Graphic::Vector(self))
}
}
@@ -341,12 +360,7 @@ impl IntoGraphicList for List<Gradient> {
impl IntoGraphicList for List<String> {
fn into_graphic_list(self) -> List<Graphic> {
let layer_path = self.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).cloned();
let mut graphic_list = List::new_from_element(Graphic::Text(self));
if let Some(layer_path) = layer_path {
graphic_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
}
graphic_list
List::new_from_element(Graphic::Text(self))
}
}
@@ -634,11 +648,52 @@ impl<T: Clone> OmitIndex for List<T> {
mod tests {
use super::*;
use core_types::list::List;
use core_types::uuid::NodeId;
fn vector_graphic() -> Graphic {
Graphic::Vector(List::new_from_element(Vector::default()))
}
fn vector_list_stamped_with_layers(layers: [u64; 2]) -> List<Vector> {
let mut list = List::new();
for layer in layers {
let mut item = Item::new_from_element(Vector::default());
item.set_attribute(ATTR_EDITOR_LAYER_PATH, NodeIdPath::from(vec![NodeId(layer)]));
list.push(item);
}
list
}
// The wrapper minted for a typed list is a container rather than a layer, so it must never claim a layer path
#[test]
fn wrapping_a_typed_list_leaves_the_wrapper_anonymous() {
let graphic_list = vector_list_stamped_with_layers([7, 9]).into_graphic_list();
assert_eq!(graphic_list.len(), 1);
assert!(!graphic_list.attribute_keys().any(|key| key == ATTR_EDITOR_LAYER_PATH));
}
// Round-tripping through that wrapper must not collapse the items' distinct stamps onto item 0's
#[test]
fn round_trip_through_the_wrapper_preserves_per_item_layer_paths() {
let flattened: List<Vector> = vector_list_stamped_with_layers([7, 9]).into_flattened_list();
let layers = (0..flattened.len())
.map(|index| {
flattened
.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index)
.0
.iter_element_values()
.next_back()
.copied()
})
.collect::<Vec<_>>();
assert_eq!(layers, [Some(NodeId(7)), Some(NodeId(9))]);
}
// Flattening must not invent attribute columns that neither the parent graphic nor the child carried
#[test]
fn flatten_does_not_invent_attributes() {

View File

@@ -940,49 +940,61 @@ impl Render for List<Graphic> {
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let element = self.element(index).unwrap();
render.parent_tag(
"g",
|attributes| {
let matrix = format_transform_matrix(transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
let matrix = format_transform_matrix(transform);
let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled();
let mut masked_by = None;
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
}
if next_clips && mask_state.is_none() {
let uuid = generate_uuid();
let mask_type = if element.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask };
if blend_mode != BlendMode::default() {
attributes.push("style", blend_mode.render());
}
let mut svg = SvgRender::new();
element.render_svg(&mut svg, &render_params.for_clipper());
let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled();
// The def is resolved in this list's space, so the masker's own transform has to be baked into it
let masker = match matrix.is_empty() {
true => svg.svg.to_svg_string(),
false => format!(r##"<g transform="{matrix}">{}</g>"##, svg.svg.to_svg_string()),
};
if next_clips && mask_state.is_none() {
let uuid = generate_uuid();
let mask_type = if element.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask };
mask_state = Some((uuid, mask_type));
let mut svg = SvgRender::new();
element.render_svg(&mut svg, &render_params.for_clipper());
render.svg_defs.push_str(&svg.svg_defs);
mask_type.write_to_defs(&mut render.svg_defs, uuid, masker);
write!(&mut attributes.0.svg_defs, r##"{}"##, svg.svg_defs).unwrap();
mask_type.write_to_defs(&mut attributes.0.svg_defs, uuid, svg.svg.to_svg_string());
} else if let Some((uuid, mask_type)) = mask_state {
if !next_clips {
mask_state = None;
mask_state = Some((uuid, mask_type));
} else if let Some((uuid, mask_type)) = mask_state {
if !next_clips {
mask_state = None;
}
masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})")));
}
let render_item = |render: &mut SvgRender| {
render.parent_tag(
"g",
|attributes| {
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix.clone());
}
let id = format!("mask-{uuid}");
let selector = format!("url(#{id})");
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
}
attributes.push(mask_type.to_attribute(), selector);
}
},
|render| {
element.render_svg(render, render_params);
},
);
if blend_mode != BlendMode::default() {
attributes.push("style", blend_mode.render());
}
},
|render| element.render_svg(render, render_params),
);
};
// The mask rides an untransformed wrapper so it resolves in this list's space rather than the item's own
match masked_by {
Some((attribute, selector)) => render.parent_tag("g", |attributes| attributes.push(attribute, selector), render_item),
None => render_item(render),
}
}
}
@@ -1156,222 +1168,263 @@ impl Render for List<Graphic> {
}
}
impl Render for List<Vector> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
for index in 0..self.len() {
let Some(vector) = self.element(index) else { continue };
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
/// Emits one item of a `List<Vector>` as SVG, with no wrapping group of its own.
fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) {
let item_transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let blend_mode_attr: BlendMode = list.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
let opacity_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform));
let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform);
let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform);
let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse());
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = vector.bounding_box().unwrap_or_default();
let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds);
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform));
let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform);
let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform);
let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse());
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = vector.bounding_box().unwrap_or_default();
let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds);
let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]);
let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]);
let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]);
let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]);
let mut path = String::new();
let mut path = String::new();
for mut bezpath in vector.stroke_bezpath_iter() {
bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
path.push_str(bezpath.to_svg().as_str());
for mut bezpath in vector.stroke_bezpath_iter() {
bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
path.push_str(bezpath.to_svg().as_str());
}
let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) {
MaskType::Clip
} else {
MaskType::Mask
};
let fill_graphic_list = graphic_list_at(list, index, ATTR_FILL);
let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0));
let stroke_graphic_list = graphic_list_at(list, index, ATTR_STROKE);
let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0));
let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed());
let can_draw_aligned_stroke = path_is_closed
&& vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
&& stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent());
let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip);
let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order;
let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow);
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill();
if needs_separate_alignment_fill && !wants_stroke_below {
emit_svg_fill_path(
render,
path.clone(),
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
render_params,
);
}
let push_id = needs_separate_alignment_fill.then_some({
let id = format!("alignment-{}", generate_uuid());
let mut cloned_vector = vector.clone();
cloned_vector.stroke = None;
// The mask must draw at full alpha so the SVG `<mask>`/`<clipPath>` fully zeroes the path interior.
// The wrapping SVG group (above) handles the user-set opacity.
let mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
let vector_item = List::new_from_item(mask_item);
(id, mask_type, vector_item)
});
if use_face_fill {
for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) {
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
let face_d = face_path.to_svg();
emit_svg_fill_path(
render,
face_d,
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
render_params,
);
}
}
render.leaf_tag("path", |attributes| {
attributes.push("d", path.clone());
let matrix = format_transform_matrix(element_transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
let defs = &mut attributes.0.svg_defs;
if let Some((ref id, mask_type, ref vector_item)) = push_id {
let mut svg = SvgRender::new();
vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
let stroke = vector.stroke.as_ref().unwrap();
// `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed`
let (largest_scale, _) = singular_values(applied_stroke_transform);
let inflation = stroke.max_aabb_inflation(true) * largest_scale;
let quad = Quad::from_box(transformed_bounds).inflate(inflation);
let (x, y) = quad.top_left().into();
let (width, height) = (quad.bottom_right() - quad.top_left()).into();
write!(defs, r##"{}"##, svg.svg_defs).unwrap();
let rect = format!(r##"<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="white" />"##);
match mask_type {
MaskType::Clip => write!(defs, r##"<clipPath id="{id}">{}</clipPath>"##, svg.svg.to_svg_string()).unwrap(),
MaskType::Mask => write!(
defs,
r##"<mask id="{id}" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" x="{x}" y="{y}" width="{width}" height="{height}">{}{}</mask>"##,
rect,
svg.svg.to_svg_string()
)
.unwrap(),
}
}
let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) {
MaskType::Clip
} else {
MaskType::Mask
};
let mut render_params = render_params.clone();
render_params.aligned_strokes = can_draw_aligned_stroke;
render_params.override_paint_order = override_paint_order;
let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL);
let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0));
let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE);
let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0));
let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed());
let can_draw_aligned_stroke = path_is_closed
&& vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
&& stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent());
let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip);
let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order;
let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow);
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill();
if needs_separate_alignment_fill && !wants_stroke_below {
emit_svg_fill_path(
render,
path.clone(),
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
render_params,
);
}
let push_id = needs_separate_alignment_fill.then_some({
let id = format!("alignment-{}", generate_uuid());
let mut cloned_vector = vector.clone();
cloned_vector.stroke = None;
// The mask must draw at full alpha so the SVG `<mask>`/`<clipPath>` fully zeroes the path interior.
// The wrapping SVG group (above) handles the user-set opacity.
let mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
let vector_item = List::new_from_item(mask_item);
(id, mask_type, vector_item)
});
if use_face_fill {
for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) {
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
let face_d = face_path.to_svg();
emit_svg_fill_path(
render,
face_d,
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
render_params,
);
}
}
render.leaf_tag("path", |attributes| {
attributes.push("d", path.clone());
let matrix = format_transform_matrix(element_transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
let defs = &mut attributes.0.svg_defs;
if let Some((ref id, mask_type, ref vector_item)) = push_id {
let mut svg = SvgRender::new();
vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
let stroke = vector.stroke.as_ref().unwrap();
// `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed`
let (largest_scale, _) = singular_values(applied_stroke_transform);
let inflation = stroke.max_aabb_inflation(true) * largest_scale;
let quad = Quad::from_box(transformed_bounds).inflate(inflation);
let (x, y) = quad.top_left().into();
let (width, height) = (quad.bottom_right() - quad.top_left()).into();
write!(defs, r##"{}"##, svg.svg_defs).unwrap();
let rect = format!(r##"<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="white" />"##);
match mask_type {
MaskType::Clip => write!(defs, r##"<clipPath id="{id}">{}</clipPath>"##, svg.svg.to_svg_string()).unwrap(),
MaskType::Mask => write!(
defs,
r##"<mask id="{id}" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" x="{x}" y="{y}" width="{width}" height="{height}">{}{}</mask>"##,
rect,
svg.svg.to_svg_string()
)
.unwrap(),
}
}
let mut render_params = render_params.clone();
render_params.aligned_strokes = can_draw_aligned_stroke;
render_params.override_paint_order = override_paint_order;
let stroke_shape_attribute = vector
.stroke
.as_ref()
.map(|stroke| {
if stroke_graphic_list.as_deref().is_some_and(is_paint_present) {
stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke)
} else {
String::new()
}
})
.unwrap_or_default();
// Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback
let stroke_visible = vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent());
let stroke_attribute = if stroke_visible {
stroke_graphic_list
.as_deref()
.map(|list| {
// Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke.
// Only clipping-based paints need the stroke-inclusive bbox.
let paint_bounds = match list.element(0) {
Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) => bounds_matrix,
_ => stroke_bounds_matrix,
};
list.render(defs, item_transform, element_transform, applied_stroke_transform, paint_bounds, &render_params, PaintTarget::Stroke)
})
.unwrap_or_else(|| r#" stroke="none""#.to_string())
let stroke_shape_attribute = vector
.stroke
.as_ref()
.map(|stroke| {
if stroke_graphic_list.as_deref().is_some_and(is_paint_present) {
stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke)
} else {
String::new()
};
let fill_attribute = if needs_separate_alignment_fill || use_face_fill {
r#" fill="none""#.to_string()
} else {
fill_graphic_list
.as_deref()
.map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill))
.unwrap_or_else(|| r#" fill="none""#.to_string())
};
if let Some((id, mask_type, _)) = push_id {
let selector = format!("url(#{id})");
attributes.push(mask_type.to_attribute(), selector);
}
attributes.push_val(fill_attribute);
attributes.push_val(stroke_shape_attribute);
attributes.push_val(stroke_attribute);
})
.unwrap_or_default();
if vector.is_branching() && !use_face_fill {
attributes.push("fill-rule", "evenodd");
// Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback
let stroke_visible = vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent());
let stroke_attribute = if stroke_visible {
stroke_graphic_list
.as_deref()
.map(|list| {
// Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke.
// Only clipping-based paints need the stroke-inclusive bbox.
let paint_bounds = match list.element(0) {
Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) => bounds_matrix,
_ => stroke_bounds_matrix,
};
list.render(defs, item_transform, element_transform, applied_stroke_transform, paint_bounds, &render_params, PaintTarget::Stroke)
})
.unwrap_or_else(|| r#" stroke="none""#.to_string())
} else {
String::new()
};
let fill_attribute = if needs_separate_alignment_fill || use_face_fill {
r#" fill="none""#.to_string()
} else {
fill_graphic_list
.as_deref()
.map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill))
.unwrap_or_else(|| r#" fill="none""#.to_string())
};
if let Some((id, mask_type, _)) = push_id {
let selector = format!("url(#{id})");
attributes.push(mask_type.to_attribute(), selector);
}
attributes.push_val(fill_attribute);
attributes.push_val(stroke_shape_attribute);
attributes.push_val(stroke_attribute);
if vector.is_branching() && !use_face_fill {
attributes.push("fill-rule", "evenodd");
}
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
}
if blend_mode_attr != BlendMode::default() {
attributes.push("style", blend_mode_attr.render());
}
});
// When splitting passes and stroke is below, draw the fill after the stroke.
if needs_separate_alignment_fill && wants_stroke_below {
emit_svg_fill_path(
render,
path.clone(),
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
render_params,
);
}
}
impl Render for List<Vector> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
let mut clip_mask_state: Option<(u64, MaskType)> = None;
for index in 0..self.len() {
let Some(vector) = self.element(index) else { continue };
// A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares
let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::<bool>(ATTR_CLIPPING_MASK, index + 1);
let mut masked_by = None;
if next_clips && clip_mask_state.is_none() {
let masker = Graphic::Vector(List::new_from_item(Item::from_parts(vector.clone(), self.clone_item_attributes(index))));
let mask_type = if masker.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask };
let uuid = generate_uuid();
let mut masker_svg = SvgRender::new();
masker.render_svg(&mut masker_svg, &render_params.for_clipper());
render.svg_defs.push_str(&masker_svg.svg_defs);
mask_type.write_to_defs(&mut render.svg_defs, uuid, masker_svg.svg.to_svg_string());
clip_mask_state = Some((uuid, mask_type));
} else if let Some((uuid, mask_type)) = clip_mask_state {
if !next_clips {
clip_mask_state = None;
}
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
}
masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})")));
}
if blend_mode_attr != BlendMode::default() {
attributes.push("style", blend_mode_attr.render());
}
});
// When splitting passes and stroke is below, draw the fill after the stroke.
if needs_separate_alignment_fill && wants_stroke_below {
emit_svg_fill_path(
render,
path.clone(),
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
render_params,
);
// Item geometry is baked into the path data instead of a group transform, so mask coordinates line up
match masked_by {
Some((attribute, selector)) => render.parent_tag(
"g",
|attributes| attributes.push(attribute, selector),
|render| render_vector_item_svg(self, index, vector, render, render_params),
),
None => render_vector_item_svg(self, index, vector, render, render_params),
}
}
}
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
let mut clip_masker: Option<List<Vector>> = None;
for index in 0..self.len() {
use graphic_types::vector_types::vector;
@@ -1423,23 +1476,53 @@ impl Render for List<Vector> {
let can_draw_aligned_stroke =
!stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed());
// A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares
let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::<bool>(ATTR_CLIPPING_MASK, index + 1);
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. || blend_mode_attr != BlendMode::default() {
layer = true;
let needs_blend_layer = opacity < 1. || blend_mode_attr != BlendMode::default();
// Shared by the blend and clipping layers below, so it is only worth deriving when one of them is pushed
let layer_geometry = (needs_blend_layer || clip_masker.is_some()).then(|| {
// `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`.
// Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform.
let (_, smallest_scale) = singular_values(applied_stroke_transform);
let stroke_inflation = stroke.map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke));
let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. };
let quad = Quad::from_box(layer_bounds).inflate(inflate_amount);
let layer_bounds = quad.bounding_box();
scene.push_layer(
peniko::Fill::NonZero,
peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver),
opacity,
let bounds = Quad::from_box(layer_bounds).inflate(inflate_amount).bounding_box();
(
kurbo::Affine::new(multiplied_transform.to_cols_array()),
&kurbo::Rect::new(layer_bounds[0].x, layer_bounds[0].y, layer_bounds[1].x, layer_bounds[1].y),
);
kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y),
)
});
if needs_blend_layer && let Some((layer_affine, layer_rect)) = layer_geometry {
layer = true;
scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), opacity, layer_affine, &layer_rect);
}
// Pushed inside the blend layer so the mask cuts this item's own paint rather than the composited result
let mut clip_layers = false;
if next_clips && clip_masker.is_none() {
clip_masker = Some(List::new_from_item(Item::from_parts(element.clone(), self.clone_item_attributes(index))));
} else if let Some(masker) = clip_masker.as_ref() {
if let Some((layer_affine, layer_rect)) = layer_geometry {
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect);
masker.render_to_vello(scene, parent_transform, context, &render_params.for_clipper());
scene.push_layer(
peniko::Fill::NonZero,
peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn),
1.,
layer_affine,
&layer_rect,
);
clip_layers = true;
}
if !next_clips {
clip_masker = None;
}
}
let use_layer = can_draw_aligned_stroke;
@@ -1643,6 +1726,11 @@ impl Render for List<Vector> {
}
}
if clip_layers {
scene.pop_layer();
scene.pop_layer();
}
// If we pushed a layer for opacity or a blend mode, we need to pop it
if layer {
scene.pop_layer();
@@ -1652,17 +1740,8 @@ impl Render for List<Vector> {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
// Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph.
// Targets are baked relative to item 0's transform since `Graphic::collect_metadata` records that as `local_transforms[element_id]`.
let item_zero_transform: DAffine2 = if !self.is_empty() {
self.attribute_cloned_or_default(ATTR_TRANSFORM, 0)
} else {
DAffine2::IDENTITY
};
let item_zero_inverse = if transform_is_invertible(item_zero_transform) {
item_zero_transform.inverse()
} else {
DAffine2::IDENTITY
};
// Targets are baked relative to the first item carrying each element_id, since that is the transform recorded as its `local_transforms` entry.
let mut reference_transforms: HashMap<NodeId, DAffine2> = HashMap::new();
let mut accumulated_click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
let mut accumulated_outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
@@ -1674,18 +1753,17 @@ impl Render for List<Vector> {
let layer = layer_path.iter_element_values().next_back().copied();
if let Some(element_id) = caller_element_id.or(layer) {
// When recovering element_id from the item's editor:layer_path tag (because the caller
// passed None), also store the transform metadata that Graphic::collect_metadata
// normally provides but skipped due to the None element_id.
if caller_element_id.is_none() {
metadata.upstream_footprints.entry(element_id).or_insert(footprint);
metadata.local_transforms.entry(element_id).or_insert(item_zero_transform);
}
let reference_transform = *reference_transforms.entry(element_id).or_insert(transform);
let reference_inverse = if transform_is_invertible(reference_transform) {
reference_transform.inverse()
} else {
DAffine2::IDENTITY
};
// Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes)
let click_target_vector = self.attribute::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source);
let item_relative_transform = item_zero_inverse * transform;
let item_relative_transform = reference_inverse * transform;
let mut click_targets_unwrapped = Vec::new();
extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform);
@@ -1735,6 +1813,15 @@ impl Render for List<Vector> {
for (element_id, targets) in accumulated_outlines {
metadata.outlines.insert(element_id, targets);
}
// Recovering element_id from `editor:layer_path` means `Graphic::collect_metadata` skipped this transform metadata.
// It lands after the snapshot recursion above so each element keeps the pair its targets were baked against.
if caller_element_id.is_none() {
for (element_id, reference_transform) in reference_transforms {
metadata.upstream_footprints.insert(element_id, footprint);
metadata.local_transforms.insert(element_id, reference_transform);
}
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
@@ -1986,8 +2073,14 @@ impl Render for List<Raster<CPU>> {
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
for index in 0..self.len() {
// The unit square is the raster's own space, so its placement only exists in the item transform
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
subpath.apply_transform(transform);
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
}
}
}
@@ -2081,8 +2174,13 @@ impl Render for List<Raster<GPU>> {
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
for index in 0..self.len() {
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
subpath.apply_transform(transform);
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
}
}
}