Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use graphene_std::list::List;
use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{GradientSpreadMethod, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::{Artboard, Color};
use graphene_std::{Artboard, Color, MaskMode};

#[derive(ExtractField)]
pub struct GraphOperationMessageContext<'a> {
Expand Down Expand Up @@ -573,6 +573,20 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option<Color> {
Some(Color::from_rgbaf32_unchecked(r, g, b, opacity))
}

#[derive(Clone, Copy, Debug)]
struct ImportedNode {
layer: LayerNodeIdentifier,
extent: u32,
}

fn imported_group_extent(child_extents: &[u32]) -> u32 {
if child_extents.is_empty() {
0
} else {
(2 * STACK_VERTICAL_GAP as u32) * child_extents.len() as u32 - STACK_VERTICAL_GAP as u32 + child_extents.iter().sum::<u32>()
}
}

/// Import a usvg node as the root of an SVG import operation.
///
/// The root layer uses the full `move_layer_to_stack` (with push/collision logic) to correctly
Expand All @@ -597,18 +611,13 @@ fn import_usvg_node(

match node {
usvg::Node::Group(group) => {
// Collect child extents for O(n) position calculation
let mut child_extents_svg_order: Vec<u32> = Vec::new();
let mut group_extents_map: HashMap<LayerNodeIdentifier, Vec<u32>> = HashMap::new();

// Enable import mode: skips expensive is_acyclic checks and per-node cache invalidation
// during wiring since we're building a known tree structure where cycles are impossible
modify_inputs.import = true;

for child in group.children() {
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map);
child_extents_svg_order.push(extent);
}
let child_extents_svg_order = import_usvg_group_children(modify_inputs, group, layer, graphite_gradient_stops, &mut group_extents_map);

modify_inputs.import = false;
modify_inputs.layer_node = Some(layer);
Expand Down Expand Up @@ -638,8 +647,125 @@ fn import_usvg_node(
}
}

/// Imports a group's children in SVG paint order, adding a hidden mask base for a resolved SVG mask.
fn import_usvg_group_children(
modify_inputs: &mut ModifyInputsContext,
group: &usvg::Group,
parent: LayerNodeIdentifier,
graphite_gradient_stops: &HashMap<String, GradientStops>,
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
) -> Vec<u32> {
import_usvg_children_with_mask(
modify_inputs,
group.children(),
group.mask(),
parent,
usvg_transform(group.abs_transform()),
graphite_gradient_stops,
group_extents_map,
)
}

/// Imports one sibling list and represents its optional SVG mask with Graphite's existing clipping-mask nodes.
fn import_usvg_children_with_mask(
modify_inputs: &mut ModifyInputsContext,
children: &[usvg::Node],
mask: Option<&usvg::Mask>,
parent: LayerNodeIdentifier,
referencing_transform: DAffine2,
graphite_gradient_stops: &HashMap<String, GradientStops>,
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
) -> Vec<u32> {
let Some(mask) = mask.filter(|_| !children.is_empty()) else {
return import_usvg_children(modify_inputs, children, parent, graphite_gradient_stops, group_extents_map);
};

let imported_mask = import_usvg_mask(modify_inputs, mask, parent, 0, referencing_transform, graphite_gradient_stops, group_extents_map);
let imported_content = import_usvg_children_as_group(modify_inputs, children, parent, 0, graphite_gradient_stops, group_extents_map);

modify_inputs.layer_node = Some(imported_content.layer);
modify_inputs.clip_mode_set(true);

vec![imported_mask.extent, imported_content.extent]
}

/// Imports a sibling list directly into an existing Graphite group.
fn import_usvg_children(
modify_inputs: &mut ModifyInputsContext,
children: &[usvg::Node],
parent: LayerNodeIdentifier,
graphite_gradient_stops: &HashMap<String, GradientStops>,
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
) -> Vec<u32> {
children
.iter()
.map(|child| import_usvg_node_inner(modify_inputs, child, NodeId::new(), parent, 0, graphite_gradient_stops, group_extents_map).extent)
.collect()
}

/// Imports a sibling list as one composited Graphite group so a parent mask can be applied exactly once.
fn import_usvg_children_as_group(
modify_inputs: &mut ModifyInputsContext,
children: &[usvg::Node],
parent: LayerNodeIdentifier,
insert_index: usize,
graphite_gradient_stops: &HashMap<String, GradientStops>,
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
) -> ImportedNode {
let layer = modify_inputs.create_layer(NodeId::new());
modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]);
modify_inputs.layer_node = Some(layer);

let child_extents = import_usvg_children(modify_inputs, children, layer, graphite_gradient_stops, group_extents_map);
let extent = imported_group_extent(&child_extents);
group_extents_map.insert(layer, child_extents);

ImportedNode { layer, extent }
}

/// Imports a resolved SVG mask as one hidden Graphite mask-base layer.
fn import_usvg_mask(
modify_inputs: &mut ModifyInputsContext,
mask: &usvg::Mask,
parent: LayerNodeIdentifier,
insert_index: usize,
referencing_transform: DAffine2,
graphite_gradient_stops: &HashMap<String, GradientStops>,
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
) -> ImportedNode {
let layer = modify_inputs.create_layer(NodeId::new());
modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]);
modify_inputs.layer_node = Some(layer);

let child_extents = import_usvg_children_with_mask(
modify_inputs,
mask.root().children(),
mask.mask(),
layer,
DAffine2::IDENTITY,
graphite_gradient_stops,
group_extents_map,
);

let extent = imported_group_extent(&child_extents);
group_extents_map.insert(layer, child_extents);

modify_inputs.layer_node = Some(layer);
modify_inputs.transform_set(referencing_transform, TransformIn::Local, false);
let mask_mode = match mask.kind() {
usvg::MaskType::Alpha => MaskMode::Alpha,
usvg::MaskType::Luminance => MaskMode::Luminance,
};
modify_inputs.mask_mode_set(mask_mode);
// Independent fill opacity hides the mask artwork during normal rendering but is
// intentionally ignored by the renderer's mask pass, preserving its source paint and alpha.
modify_inputs.opacity_fill_set(0.);

ImportedNode { layer, extent }
}

/// Recursively import a usvg node as a descendant of the root import layer.
/// Uses lightweight wiring (no push/collision) and returns the subtree extent for position calculation.
/// Uses lightweight wiring (no push/collision) and returns the layer plus its subtree extent for position calculation.
///
/// The subtree extent represents the additional vertical grid units that this node's descendants
/// occupy below the node's position. This is used to calculate correct y_offsets between siblings.
Expand All @@ -651,26 +777,17 @@ fn import_usvg_node_inner(
insert_index: usize,
graphite_gradient_stops: &HashMap<String, GradientStops>,
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
) -> u32 {
) -> ImportedNode {
let layer = modify_inputs.create_layer(id);
modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]);
modify_inputs.layer_node = Some(layer);

match node {
let extent = match node {
usvg::Node::Group(group) => {
let mut child_extents: Vec<u32> = Vec::new();
for child in group.children() {
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map);
child_extents.push(extent);
}
let child_extents = import_usvg_group_children(modify_inputs, group, layer, graphite_gradient_stops, group_extents_map);
modify_inputs.layer_node = Some(layer);

let n = child_extents.len();
let total_extent = if n == 0 {
0
} else {
(2 * STACK_VERTICAL_GAP as u32) * n as u32 - STACK_VERTICAL_GAP as u32 + child_extents.iter().sum::<u32>()
};
let total_extent = imported_group_extent(&child_extents);
group_extents_map.insert(layer, child_extents);
total_extent
}
Expand All @@ -688,7 +805,9 @@ fn import_usvg_node_inner(
modify_inputs.fill_color_set(Some(Color::BLACK));
0
}
}
};

ImportedNode { layer, extent }
}

/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
Expand Down Expand Up @@ -861,3 +980,148 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g
usvg::Paint::Pattern(_) => warn!("SVG patterns are not currently supported"),
};
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::test_prelude::*;
use graphene_std::renderer::{Render, RenderParams, RenderSvgSegmentList, SvgRender};

#[tokio::test]
async fn svg_group_alpha_mask_imports_as_hidden_clipping_mask() {
let mut editor = EditorTestUtils::create();
editor.new_document().await;

let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<mask id="circle-mask" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
<circle cx="64" cy="64" r="48" fill="white" fill-opacity="0.5" />
</mask>
</defs>
<g transform="translate(10 20)" mask="url(#circle-mask)">
<rect x="0" width="96" height="128" fill="#2563eb" fill-opacity="0.5" />
<rect x="32" width="96" height="128" fill="#dc2626" fill-opacity="0.5" />
</g>
</svg>"##;

editor
.handle_message(GraphOperationMessage::NewSvg {
id: NodeId::new(),
svg: svg.to_string(),
transform: DAffine2::IDENTITY,
parent: LayerNodeIdentifier::ROOT_PARENT,
insert_index: 0,
center: false,
})
.await;

let instrumented = editor.eval_graph().await.expect("alpha-masked SVG import should evaluate");

let clip_values: Vec<_> = instrumented.grab_all_input::<graphene_std::blending_nodes::clipping_mask::ClipInput>(&editor.runtime).collect();
assert_eq!(clip_values, vec![true], "the composited visible group should receive the SVG mask exactly once");
let mask_mode_values: Vec<_> = instrumented.grab_all_input::<graphene_std::blending_nodes::mask_mode::ModeInput>(&editor.runtime).collect();
assert!(mask_mode_values.is_empty(), "alpha masks should retain the default behavior without an additional Mask Mode node");

let has_fill_values: Vec<_> = instrumented.grab_all_input::<graphene_std::blending_nodes::opacity::HasFillInput>(&editor.runtime).collect();
assert_eq!(has_fill_values, vec![true], "the imported mask base should enable independent fill opacity");

let fill_values: Vec<_> = instrumented.grab_all_input::<graphene_std::blending_nodes::opacity::FillInput>(&editor.runtime).collect();
assert_eq!(fill_values, vec![0.], "the imported mask artwork should be hidden during ordinary rendering");

let paint_fills: Vec<_> = instrumented.grab_all_input::<graphene_std::vector::fill::FillInput<List<Color>>>(&editor.runtime).collect();
assert!(
paint_fills.iter().filter_map(|fill| fill.element(0)).any(|color| {
let [red, green, blue, alpha] = color.to_gamma_srgb_channels();
(red - 1.).abs() < 1e-6 && (green - 1.).abs() < 1e-6 && (blue - 1.).abs() < 1e-6 && (alpha - 0.5).abs() < 1e-6
}),
"alpha mask paint should pass through the ordinary SVG fill importer; got {paint_fills:?}"
);

let translations: Vec<_> = instrumented.grab_all_input::<graphene_std::transform_nodes::transform::TranslationInput>(&editor.runtime).collect();
let referencing_translation_count = translations.iter().filter(|translation| translation.abs_diff_eq(DVec2::new(10., 20.), 1e-10)).count();
assert!(
referencing_translation_count >= 2,
"the visible artwork and independent mask subroot should both inherit the referencing transform; got {translations:?}"
);

let rendered_group_svgs: Vec<_> = instrumented
.grab_all_input::<graphene_std::graphic::extend::NewInput<graphene_std::Graphic>>(&editor.runtime)
.map(|graphics| {
let mut render = SvgRender::new();
graphics.render_svg(&mut render, &RenderParams::default());
format!("{}{}", render.svg_defs, render.svg.to_svg_string())
})
.collect();
let rendered_group_svg = rendered_group_svgs
.iter()
.find(|svg| svg.contains("<mask") && svg.contains("mask=\"url(#mask-"))
.unwrap_or_else(|| panic!("the imported mask attributes should reach the existing SVG mask renderer; intermediate SVGs: {rendered_group_svgs:#?}"));
assert!(rendered_group_svg.contains("opacity=\"0\""), "the mask base should remain hidden in the normal render pass");
assert_eq!(
rendered_group_svg.matches("mask=\"url(#mask-").count(),
1,
"the mask should wrap the composited visible group once, not each semi-transparent child independently"
);
}

async fn assert_luminance_mask_import(mask_type_attribute: &str) {
let mut editor = EditorTestUtils::create();
editor.new_document().await;

let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<mask id="luminance-mask"{mask_type_attribute} maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
<rect width="128" height="128" fill="white" />
<circle cx="64" cy="64" r="32" fill="black" />
</mask>
</defs>
<rect width="128" height="128" fill="#2563eb" mask="url(#luminance-mask)" />
</svg>"##
);

editor
.handle_message(GraphOperationMessage::NewSvg {
id: NodeId::new(),
svg,
transform: DAffine2::IDENTITY,
parent: LayerNodeIdentifier::ROOT_PARENT,
insert_index: 0,
center: false,
})
.await;

let instrumented = editor.eval_graph().await.expect("luminance-masked SVG import should evaluate");
let clip_values: Vec<_> = instrumented.grab_all_input::<graphene_std::blending_nodes::clipping_mask::ClipInput>(&editor.runtime).collect();
assert_eq!(clip_values, vec![true], "the composited visible group should receive the luminance mask exactly once");

let mask_mode_values: Vec<_> = instrumented.grab_all_input::<graphene_std::blending_nodes::mask_mode::ModeInput>(&editor.runtime).collect();
assert_eq!(mask_mode_values, vec![MaskMode::Luminance], "the imported hidden mask source should preserve usvg's resolved mask kind");

let fill_values: Vec<_> = instrumented.grab_all_input::<graphene_std::blending_nodes::opacity::FillInput>(&editor.runtime).collect();
assert_eq!(fill_values, vec![0.], "the luminance mask artwork should be hidden during ordinary rendering");

let rendered_group_svgs: Vec<_> = instrumented
.grab_all_input::<graphene_std::graphic::extend::NewInput<graphene_std::Graphic>>(&editor.runtime)
.map(|graphics| {
let mut render = SvgRender::new();
graphics.render_svg(&mut render, &RenderParams::default());
format!("{}{}", render.svg_defs, render.svg.to_svg_string())
})
.collect();
assert!(
rendered_group_svgs.iter().any(|svg| svg.contains("mask-type=\"luminance\"") && svg.contains("mask=\"url(#mask-")),
"the imported mode should reach the SVG renderer; intermediate SVGs: {rendered_group_svgs:#?}"
);
}

#[tokio::test]
async fn svg_explicit_luminance_mask_preserves_its_mode() {
assert_luminance_mask_import(r#" mask-type="luminance""#).await;
}

#[tokio::test]
async fn svg_mask_defaults_to_luminance() {
assert_luminance_mask_import("").await;
}
}
Loading