mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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:
@@ -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.));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user