diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 96f8d15c51..5d8cb2b7ad 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -200,21 +200,6 @@ pub fn is_paint_present(graphic_list: &List) -> bool { graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty()) } -/// Look up the paint graphics stored under attribute for a vector item, in the canonical `List` form. -pub fn graphic_list_at<'a>(list: &'a List, index: usize, attribute: &str) -> Option>> { - list.attribute::>>(attribute, index) - .and_then(|optional| optional.as_ref()) - .map(Cow::Borrowed) - // Treat a blank paint attribute as absent so an empty attribute doesn't count as painted - .filter(|graphic_list| is_paint_present(graphic_list)) -} - -/// Whether the item carries a non-blank canonical `List` paint attribute, -/// checked by borrowing without cloning the renderable list. -pub fn has_paint_at(list: &List, index: usize, attribute: &str) -> bool { - graphic_list_at(list, index, attribute).is_some() -} - /// Look up the paint graphics stored under the marker `A`, in the canonical `List` form. pub fn paint_graphics<'a, A, S>(source: &'a S, index: usize) -> Option>> where diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 654b147526..f00a424914 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -21,15 +21,15 @@ use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; -use graphic_types::graphic::{graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute}; -use graphic_types::markers::EditorMergedLayers; +use graphic_types::graphic::{has_paint, is_paint_present, paint_graphics, set_paint_attribute}; +use graphic_types::markers::{EditorMergedLayers, Fill, Stroke}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture}; use graphic_types::vector_types::gradient::{GradientStops, GradientType}; use graphic_types::vector_types::markers::{GradientType as GradientTypeAttr, SpreadMethod}; use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; -use graphic_types::{ATTR_FILL, ATTR_STROKE, Artboard, Graphic, Vector}; +use graphic_types::{ATTR_FILL, Artboard, Graphic, Vector}; use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; @@ -703,136 +703,152 @@ impl Render for Graphic { } } -/// Reads the artboard metadata for the item at `index` from a `List`. -fn read_artboard_attributes(list: &List, index: usize) -> (DVec2, DVec2, Color, bool) { - let location: DVec2 = list.attr::(index); - let dimensions: DVec2 = list.attr::(index); - let background: Color = list.attr::(index); - let clip: bool = list.attr::(index); +/// Reads the artboard metadata for the item at `index`. +fn read_artboard_attributes(source: &S, index: usize) -> (DVec2, DVec2, Color, bool) { + let location: DVec2 = source.attr::(index); + let dimensions: DVec2 = source.attr::(index); + let background: Color = source.attr::(index); + let clip: bool = source.attr::(index); (location, dimensions, background, clip) } +fn render_artboard_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..source.lane_count() { + let Some(content) = source.element(index).map(Artboard::as_graphic_list) else { continue }; + let (location, dimensions, background, clip) = read_artboard_attributes(source, index); + + let x = location.x.min(location.x + dimensions.x); + let y = location.y.min(location.y + dimensions.y); + let width = dimensions.x.abs(); + let height = dimensions.y.abs(); + + // Background + render.leaf_tag("rect", |attributes| { + attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex())); + if background.a() < 1. { + attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); + } + attributes.push("x", x.to_string()); + attributes.push("y", y.to_string()); + attributes.push("width", width.to_string()); + attributes.push("height", height.to_string()); + }); + + // Artwork + render.parent_tag( + // SVG group tag + "g", + // Group tag attributes + |attributes| { + let matrix = format_transform_matrix(DAffine2::from_translation(location)); + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix); + } + + if clip { + let id = format!("artboard-{}", generate_uuid()); + let selector = format!("url(#{id})"); + + write!( + &mut attributes.0.svg_defs, + r##""##, + dimensions.x, dimensions.y, + ) + .unwrap(); + attributes.push("clip-path", selector); + } + }, + // Artwork content + |render| { + let mut render_params = render_params.clone(); + render_params.artboard_background = Some(background); + content.render_svg(render, &render_params); + }, + ); + } +} + +fn render_artboard_vello>(source: &S, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + use vello::peniko; + + for index in 0..source.lane_count() { + let Some(content) = source.element(index).map(Artboard::as_graphic_list) else { continue }; + let (location, dimensions, background, clip) = read_artboard_attributes(source, index); + + let [a, b] = [location, location + dimensions]; + let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); + + let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); + + let color = SRGBA8::from(background).to_peniko_color(); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); + scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); + scene.pop_layer(); + + if clip { + scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect); + } + + // Since the content's transform is right multiplied in when rendering the content, we just need to right multiply by the artboard offset here. + let child_transform = transform * DAffine2::from_translation(location); + let mut render_params = render_params.clone(); + render_params.artboard_background = Some(background); + content.render_to_vello(scene, child_transform, context, &render_params); + if clip { + scene.pop_layer(); + } + } +} + +fn collect_artboard_metadata>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint) { + for index in 0..source.lane_count() { + let Some(content) = source.element(index).map(Artboard::as_graphic_list) else { continue }; + let (location, dimensions, _background, clip) = read_artboard_attributes(source, index); + + let layer_path: &[NodeId] = source.attr::(index); + let element_id = layer_path.last().copied(); + + if let Some(element_id) = element_id { + let subpath = Subpath::new_rectangle(DVec2::ZERO, dimensions); + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); + metadata.upstream_footprints.insert(element_id, footprint); + metadata.local_transforms.insert(element_id, DAffine2::from_translation(location)); + if clip { + metadata.clip_targets.insert(element_id); + } + } + + metadata.backgrounds.push(Background { location, dimensions }); + + let mut child_footprint = footprint; + child_footprint.transform *= DAffine2::from_translation(location); + content.collect_metadata(metadata, child_footprint, None); + } +} + +fn add_artboard_upstream_click_targets>(source: &S, click_targets: &mut Vec) { + for index in 0..source.lane_count() { + let dimensions: DVec2 = source.attr::(index); + let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions); + click_targets.push(ClickTarget::new_with_subpath(subpath_rectangle, 0.)); + } +} + impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - for index in 0..self.len() { - let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue }; - let (location, dimensions, background, clip) = read_artboard_attributes(self, index); - - let x = location.x.min(location.x + dimensions.x); - let y = location.y.min(location.y + dimensions.y); - let width = dimensions.x.abs(); - let height = dimensions.y.abs(); - - // Background - render.leaf_tag("rect", |attributes| { - attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex())); - if background.a() < 1. { - attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); - } - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - }); - - // Artwork - render.parent_tag( - // SVG group tag - "g", - // Group tag attributes - |attributes| { - let matrix = format_transform_matrix(DAffine2::from_translation(location)); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - - if clip { - let id = format!("artboard-{}", generate_uuid()); - let selector = format!("url(#{id})"); - - write!( - &mut attributes.0.svg_defs, - r##""##, - dimensions.x, dimensions.y, - ) - .unwrap(); - attributes.push("clip-path", selector); - } - }, - // Artwork content - |render| { - let mut render_params = render_params.clone(); - render_params.artboard_background = Some(background); - content.render_svg(render, &render_params); - }, - ); - } + render_artboard_svg(self, render, render_params) } fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - use vello::peniko; - - for index in 0..self.len() { - let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue }; - let (location, dimensions, background, clip) = read_artboard_attributes(self, index); - - let [a, b] = [location, location + dimensions]; - let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); - - let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - - let color = SRGBA8::from(background).to_peniko_color(); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); - scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); - scene.pop_layer(); - - if clip { - scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect); - } - - // Since the content's transform is right multiplied in when rendering the content, we just need to right multiply by the artboard offset here. - let child_transform = transform * DAffine2::from_translation(location); - let mut render_params = render_params.clone(); - render_params.artboard_background = Some(background); - content.render_to_vello(scene, child_transform, context, &render_params); - if clip { - scene.pop_layer(); - } - } + render_artboard_vello(self, scene, transform, context, render_params) } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, _element_id: Option) { - for index in 0..self.len() { - let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue }; - let (location, dimensions, _background, clip) = read_artboard_attributes(self, index); - - let layer_path: &[NodeId] = self.attr::(index); - let element_id = layer_path.last().copied(); - - if let Some(element_id) = element_id { - let subpath = Subpath::new_rectangle(DVec2::ZERO, dimensions); - metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); - metadata.upstream_footprints.insert(element_id, footprint); - metadata.local_transforms.insert(element_id, DAffine2::from_translation(location)); - if clip { - metadata.clip_targets.insert(element_id); - } - } - - metadata.backgrounds.push(Background { location, dimensions }); - - let mut child_footprint = footprint; - child_footprint.transform *= DAffine2::from_translation(location); - content.collect_metadata(metadata, child_footprint, None); - } + collect_artboard_metadata(self, metadata, footprint) } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - for index in 0..self.len() { - let dimensions: DVec2 = self.attr::(index); - let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions); - click_targets.push(ClickTarget::new_with_subpath(subpath_rectangle, 0.)); - } + add_artboard_upstream_click_targets(self, click_targets) } fn contains_artboard(&self) -> bool { @@ -840,223 +856,247 @@ impl Render for List { } } -impl Render for List { - fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - let mut mask_state = None; +fn render_graphic_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) { + let mut mask_state = None; - for index in 0..self.len() { - let transform: DAffine2 = self.attr::(index); - let blend_mode: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let element = self.element(index).unwrap(); + for index in 0..source.lane_count() { + let transform: DAffine2 = source.attr::(index); + let blend_mode: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let element = source.element(index).unwrap(); - render.parent_tag( - "g", - |attributes| { - let matrix = format_transform_matrix(transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); + render.parent_tag( + "g", + |attributes| { + let matrix = format_transform_matrix(transform); + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix); + } + + 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 != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + + let next_clips = index + 1 < source.lane_count() && source.element(index + 1).unwrap().had_clip_enabled(); + + 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()); + + 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; } - 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()); - } + let id = format!("mask-{uuid}"); + let selector = format!("url(#{id})"); - if blend_mode != BlendMode::default() { - attributes.push("style", blend_mode.render()); - } - - let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled(); - - 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()); - - 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; - } - - let id = format!("mask-{uuid}"); - let selector = format!("url(#{id})"); - - attributes.push(mask_type.to_attribute(), selector); - } - }, - |render| { - element.render_svg(render, render_params); - }, - ); - } + attributes.push(mask_type.to_attribute(), selector); + } + }, + |render| { + element.render_svg(render, render_params); + }, + ); } +} - fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - let mut mask_element_and_transform = None; +fn render_graphic_vello>(source: &S, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + let mut mask_element_and_transform = None; - for index in 0..self.len() { - let item_transform: DAffine2 = self.attr::(index); - let transform = transform * item_transform; - let blend_mode_attr: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let element = self.element(index).unwrap(); + for index in 0..source.lane_count() { + let item_transform: DAffine2 = source.attr::(index); + let transform = transform * item_transform; + let blend_mode_attr: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let element = source.element(index).unwrap(); - let mut layer = false; + let mut layer = false; - let blend_mode = match render_params.render_mode { - RenderMode::Outline => peniko::Mix::Normal, - _ => blend_mode_attr.to_peniko(), - }; - let mut bounds = RenderBoundingBox::None; + let blend_mode = match render_params.render_mode { + RenderMode::Outline => peniko::Mix::Normal, + _ => blend_mode_attr.to_peniko(), + }; + let mut bounds = RenderBoundingBox::None; - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()) { + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()) { + bounds = element.bounding_box(transform, true); + + if let RenderBoundingBox::Rectangle(bounds) = bounds { + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), + opacity, + kurbo::Affine::IDENTITY, + &kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), + ); + layer = true; + } + } + + let next_clips = index + 1 < source.lane_count() && source.element(index + 1).unwrap().had_clip_enabled(); + if next_clips && mask_element_and_transform.is_none() { + mask_element_and_transform = Some((element, transform)); + + element.render_to_vello(scene, transform, context, render_params); + } else if let Some((mask_element, transform_mask)) = mask_element_and_transform { + if !next_clips { + mask_element_and_transform = None; + } + if !layer { bounds = element.bounding_box(transform, true); - - if let RenderBoundingBox::Rectangle(bounds) = bounds { - scene.push_layer( - peniko::Fill::NonZero, - peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), - opacity, - kurbo::Affine::IDENTITY, - &kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), - ); - layer = true; - } } - let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled(); - if next_clips && mask_element_and_transform.is_none() { - mask_element_and_transform = Some((element, transform)); + if let RenderBoundingBox::Rectangle(bounds) = bounds { + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - element.render_to_vello(scene, transform, context, render_params); - } else if let Some((mask_element, transform_mask)) = mask_element_and_transform { - if !next_clips { - mask_element_and_transform = None; - } - if !layer { - bounds = element.bounding_box(transform, true); - } - - if let RenderBoundingBox::Rectangle(bounds) = bounds { - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - mask_element.render_to_vello(scene, transform_mask, context, &render_params.for_clipper()); - scene.push_layer( - peniko::Fill::NonZero, - peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), - 1., - kurbo::Affine::IDENTITY, - &rect, - ); - } - - element.render_to_vello(scene, transform, context, render_params); - - if matches!(bounds, RenderBoundingBox::Rectangle(_)) { - scene.pop_layer(); - scene.pop_layer(); - } - } else { - element.render_to_vello(scene, transform, context, render_params); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + mask_element.render_to_vello(scene, transform_mask, context, &render_params.for_clipper()); + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), + 1., + kurbo::Affine::IDENTITY, + &rect, + ); } - if layer { + element.render_to_vello(scene, transform, context, render_params); + + if matches!(bounds, RenderBoundingBox::Rectangle(_)) { + scene.pop_layer(); scene.pop_layer(); } + } else { + element.render_to_vello(scene, transform, context, render_params); + } + + if layer { + scene.pop_layer(); + } + } +} + +fn collect_graphic_metadata>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { + for index in 0..source.lane_count() { + let item_transform: DAffine2 = source.attr::(index); + let layer_path: &[NodeId] = source.attr::(index); + let layer = layer_path.last().copied(); + let element = source.element(index).unwrap(); + + let mut footprint = footprint; + footprint.transform *= item_transform; + + if let Some(element_id) = layer { + element.collect_metadata(metadata, footprint, Some(element_id)); + } else { + // Recurse through anonymous wrapper items to reach nested content with editor:layer_path tags + element.collect_metadata(metadata, footprint, None); } } - fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { - for index in 0..self.len() { - let item_transform: DAffine2 = self.attr::(index); - let layer_path: &[NodeId] = self.attr::(index); - let layer = layer_path.last().copied(); - let element = self.element(index).unwrap(); + if let Some(element_id) = element_id { + let mut all_upstream_click_targets = Vec::new(); + let mut all_upstream_outlines = Vec::new(); - let mut footprint = footprint; - footprint.transform *= item_transform; + for index in 0..source.lane_count() { + let item_transform: DAffine2 = source.attr::(index); + let element = source.element(index).unwrap(); - if let Some(element_id) = layer { - element.collect_metadata(metadata, footprint, Some(element_id)); - } else { - // Recurse through anonymous wrapper items to reach nested content with editor:layer_path tags - element.collect_metadata(metadata, footprint, None); - } - } - - if let Some(element_id) = element_id { - let mut all_upstream_click_targets = Vec::new(); - let mut all_upstream_outlines = Vec::new(); - - for index in 0..self.len() { - let item_transform: DAffine2 = self.attr::(index); - let element = self.element(index).unwrap(); - - let mut new_click_targets = Vec::new(); - element.add_upstream_click_targets(&mut new_click_targets); - - for click_target in new_click_targets.iter_mut() { - click_target.apply_transform(item_transform) - } - - all_upstream_click_targets.extend(new_click_targets); - - let mut new_outlines = Vec::new(); - element.add_upstream_outline_targets(&mut new_outlines); - for outline in new_outlines.iter_mut() { - outline.apply_transform(item_transform) - } - all_upstream_outlines.extend(new_outlines); - } - - metadata.click_targets.insert(element_id, all_upstream_click_targets.into_iter().map(|x| x.into()).collect()); - metadata.outlines.insert(element_id, all_upstream_outlines.into_iter().map(|x| x.into()).collect()); - } - } - - fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - for index in 0..self.len() { - let item_transform: DAffine2 = self.attr::(index); - let element = self.element(index).unwrap(); let mut new_click_targets = Vec::new(); - element.add_upstream_click_targets(&mut new_click_targets); for click_target in new_click_targets.iter_mut() { click_target.apply_transform(item_transform) } - click_targets.extend(new_click_targets); - } - } + all_upstream_click_targets.extend(new_click_targets); - fn add_upstream_outline_targets(&self, outlines: &mut Vec) { - for index in 0..self.len() { - let item_transform: DAffine2 = self.attr::(index); - let element = self.element(index).unwrap(); let mut new_outlines = Vec::new(); - element.add_upstream_outline_targets(&mut new_outlines); - for outline in new_outlines.iter_mut() { outline.apply_transform(item_transform) } - - outlines.extend(new_outlines); + all_upstream_outlines.extend(new_outlines); } + + metadata.click_targets.insert(element_id, all_upstream_click_targets.into_iter().map(|x| x.into()).collect()); + metadata.outlines.insert(element_id, all_upstream_outlines.into_iter().map(|x| x.into()).collect()); + } +} + +fn add_graphic_upstream_click_targets>(source: &S, click_targets: &mut Vec) { + for index in 0..source.lane_count() { + let item_transform: DAffine2 = source.attr::(index); + let element = source.element(index).unwrap(); + let mut new_click_targets = Vec::new(); + + element.add_upstream_click_targets(&mut new_click_targets); + + for click_target in new_click_targets.iter_mut() { + click_target.apply_transform(item_transform) + } + + click_targets.extend(new_click_targets); + } +} + +fn add_graphic_upstream_outline_targets>(source: &S, outlines: &mut Vec) { + for index in 0..source.lane_count() { + let item_transform: DAffine2 = source.attr::(index); + let element = source.element(index).unwrap(); + let mut new_outlines = Vec::new(); + + element.add_upstream_outline_targets(&mut new_outlines); + + for outline in new_outlines.iter_mut() { + outline.apply_transform(item_transform) + } + + outlines.extend(new_outlines); + } +} + +fn graphic_contains_artboard>(source: &S) -> bool { + (0..source.lane_count()).any(|index| source.element(index).is_some_and(|element| element.contains_artboard())) +} + +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + render_graphic_svg(self, render, render_params) + } + + fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + render_graphic_vello(self, scene, transform, context, render_params) + } + + fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { + collect_graphic_metadata(self, metadata, footprint, element_id) + } + + fn add_upstream_click_targets(&self, click_targets: &mut Vec) { + add_graphic_upstream_click_targets(self, click_targets) + } + + fn add_upstream_outline_targets(&self, outlines: &mut Vec) { + add_graphic_upstream_outline_targets(self, outlines) } fn contains_artboard(&self) -> bool { - self.iter_element_values().any(|element| element.contains_artboard()) + graphic_contains_artboard(self) } fn new_ids_from_hash(&mut self, _reference: Option) { @@ -1067,210 +1107,94 @@ impl Render for List { } } -impl Render for List { - 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.attr::(index); - let blend_mode_attr: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); +fn render_vector_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..source.lane_count() { + let Some(vector) = source.element(index) else { continue }; + let item_transform: DAffine2 = source.attr::(index); + let blend_mode_attr: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); - // 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 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(self, index, ATTR_FILL); - let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); + let fill_graphic_list = paint_graphics::(source, index); + 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 stroke_graphic_list = paint_graphics::(source, index); + 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 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(); + 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 ``/`` 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(); - 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 ``/`` 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##""##); - - match mask_type { - MaskType::Clip => write!(defs, r##"{}"##, svg.svg.to_svg_string()).unwrap(), - MaskType::Mask => write!( - defs, - r##"{}{}"##, - 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()) - } 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(), + face_d, fill_graphic_list.as_deref(), item_transform, element_transform, @@ -1280,387 +1204,523 @@ impl Render for List { ); } } + + 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##""##); + + match mask_type { + MaskType::Clip => write!(defs, r##"{}"##, svg.svg.to_svg_string()).unwrap(), + MaskType::Mask => write!( + defs, + r##"{}{}"##, + 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()) + } 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, + ); + } + } +} + +fn render_vector_vello>(source: &S, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + for index in 0..source.lane_count() { + use graphic_types::vector_types::vector; + + let Some(element) = source.element(index) else { continue }; + let item_transform: DAffine2 = source.attr::(index); + let blend_mode_attr: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let multiplied_transform = parent_transform * item_transform; + let has_real_stroke = element.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 mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); + let mut element_transform = set_stroke_transform + .map(|stroke_transform| multiplied_transform * stroke_transform.inverse()) + .unwrap_or(DAffine2::IDENTITY); + if let Some(alignment_transform) = render_params.alignment_parent_transform { + applied_stroke_transform = alignment_transform; + element_transform = if transform_is_invertible(alignment_transform) { + multiplied_transform * alignment_transform.inverse() + } else { + multiplied_transform + }; + } + let layer_bounds = element.bounding_box().unwrap_or_default(); + + let mut path = kurbo::BezPath::new(); + for mut bezpath in element.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + for element in bezpath { + path.push(element); + } + } + + let fill_graphic_list = paint_graphics::(source, index); + let stroke_graphic_list = paint_graphics::(source, index); + + // If we're using opacity or a blend mode, we need to push a layer + let blend_mode = match render_params.render_mode { + RenderMode::Outline => peniko::Mix::Normal, + _ => blend_mode_attr.to_peniko(), + }; + let mut layer = false; + + // Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path). + // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since + // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. + let stroke = element.stroke.as_ref(); + let stroke_fully_transparent = stroke_graphic_list.as_ref().is_none_or(|l| l.element(0).is_none_or(|g| g.is_fully_transparent())); + 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()); + + 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; + // `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, + 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), + ); + } + + let use_layer = can_draw_aligned_stroke; + let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); + + let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| { + let Some(fill_graphic) = fill_graphic_list.as_deref() else { return }; + + for paint_index in 0..fill_graphic.len() { + let Some(paint) = fill_graphic.element(paint_index) else { continue }; + match paint { + Graphic::Color(list) => { + let Some(color) = list.element(0) else { continue }; + + let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); + scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); + } + Graphic::Gradient(list) => { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { + continue; + }; + + let inverse_element_transform = if transform_is_invertible(element_transform) { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); + scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); + } + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Group(_) => { + scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); + paint.render_to_vello(scene, multiplied_transform, context, render_params); + scene.pop_layer(); + } + }; + } + }; + + // Branching vectors without regions (e.g. mesh grids) need face-by-face fill rendering. + let use_face_fill = element.use_face_fill(); + let do_fill = |scene: &mut Scene, context: &mut RenderContext| { + if use_face_fill { + for mut face_path in element.construct_faces().filter(|face| face.area() >= 0.) { + face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + let mut kurbo_path = kurbo::BezPath::new(); + for element in face_path { + kurbo_path.push(element); + } + do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero); + } + } else if element.is_branching() { + do_fill_path(scene, context, &path, peniko::Fill::EvenOdd); + } else { + do_fill_path(scene, context, &path, peniko::Fill::NonZero); + } + }; + + let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| { + let Some(stroke_graphic_list) = stroke_graphic_list.as_deref() else { return }; + let Some(stroke) = stroke else { return }; + + for paint_index in 0..stroke_graphic_list.len() { + let Some(stroke_graphic) = stroke_graphic_list.element(paint_index) else { + continue; + }; + + let cap = match stroke.cap { + StrokeCap::Butt => Cap::Butt, + StrokeCap::Round => Cap::Round, + StrokeCap::Square => Cap::Square, + }; + let join = match stroke.join { + StrokeJoin::Miter => Join::Miter, + StrokeJoin::Bevel => Join::Bevel, + StrokeJoin::Round => Join::Round, + }; + let dash_pattern = stroke.dash_lengths.iter().map(|l| l.max(0.)).collect(); + let stroke = kurbo::Stroke { + width: stroke.weight * width_scale, + miter_limit: stroke.join_miter_limit, + join, + start_cap: cap, + end_cap: cap, + dash_pattern, + dash_offset: stroke.dash_offset, + }; + + if stroke.width <= 0. { + continue; + }; + + match stroke_graphic { + Graphic::Color(list) => { + let Some(color) = list.element(0) else { continue }; + let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); + + scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path); + } + Graphic::Gradient(list) => { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { + continue; + }; + let inverse_element_transform = if transform_is_invertible(element_transform) { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); + + scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); + } + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Group(_) => { + let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); + + scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); + stroke_graphic.render_to_vello(scene, multiplied_transform, context, render_params); + scene.pop_layer(); + } + }; + } + }; + + // Render the path + match render_params.render_mode { + RenderMode::Outline => { + let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); + + scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path); + } + _ => { + if use_layer { + let mut cloned_element = element.clone(); + cloned_element.stroke = None; + + // The mask must draw at full alpha so `SrcOut` fully zeroes the path interior. + // The outer opacity/blend layer (above) handles the user-set opacity. + let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform); + set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK)); + let vector_list = List::new_from_item(mask_item); + + let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); + // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed + let inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true)); + let (largest_scale, _) = singular_values(applied_stroke_transform); + let quad = Quad::from_box(bounds).inflate(inflation * largest_scale); + let bounds = quad.bounding_box(); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + + let compose = if stroke.is_some_and(|x| x.align == StrokeAlign::Outside) { + peniko::Compose::SrcOut + } else { + peniko::Compose::SrcIn + }; + + if wants_stroke_below { + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform)); + scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + + do_stroke(scene, 2., context); + + scene.pop_layer(); + scene.pop_layer(); + + do_fill(scene, context); + } else { + // Fill first (unclipped), then stroke (clipped) above + do_fill(scene, context); + + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform)); + scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + + do_stroke(scene, 2., context); + + scene.pop_layer(); + scene.pop_layer(); + } + } else { + // Non-aligned strokes or open paths: default order behavior + enum Op { + Fill, + Stroke, + } + + let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) { + true => [Op::Stroke, Op::Fill], + false => [Op::Fill, Op::Stroke], // Default + }; + + for operation in &order { + match operation { + Op::Fill => do_fill(scene, context), + Op::Stroke => do_stroke(scene, 1., context), + } + } + } + } + } + + // If we pushed a layer for opacity or a blend mode, we need to pop it + if layer { + scene.pop_layer(); + } + } +} + +fn collect_vector_metadata>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { + // Aggregate all items' targets per element_id so multi-item lists (e.g. 'Text' node with "Separate Glyphs" active) 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 source.lane_count() > 0 { source.attr::(0) } else { DAffine2::IDENTITY }; + let item_zero_inverse = if transform_is_invertible(item_zero_transform) { + item_zero_transform.inverse() + } else { + DAffine2::IDENTITY + }; + + let mut accumulated_click_targets: HashMap>> = HashMap::new(); + let mut accumulated_outlines: HashMap>> = HashMap::new(); + + for index in 0..source.lane_count() { + let Some(element) = source.element(index) else { continue }; + let transform: DAffine2 = source.attr::(index); + let layer_path: &[NodeId] = source.attr::(index); + let layer = layer_path.last().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); + } + + // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes) + let click_target_vector = source.attr::(index).unwrap_or(element); + + let item_relative_transform = item_zero_inverse * transform; + + let mut click_targets_unwrapped = Vec::new(); + extend_targets_from_vector(&mut click_targets_unwrapped, source, index, click_target_vector, item_relative_transform); + accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new)); + + // Outlines always use source geometry so the visual outline reflects actual letterforms + let mut outlines_unwrapped = Vec::new(); + extend_targets_from_vector(&mut outlines_unwrapped, source, index, element, item_relative_transform); + accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new)); + + // Source geometry (not the click-target override) so editing tools work on letterforms. + // Recorded together with `vector_data` from the same (first) row so stroke geometry stays consistent with the paint. + // Only item 0 is recorded since editing tools can only target a single item currently. + // If that row has no paint attribute, none is recorded. + if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) { + e.insert(Arc::new(element.clone())); + + if let Some(fill_graphic) = paint_graphics::(source, index) { + metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.into_owned())); + } + if let Some(stroke_graphic) = paint_graphics::(source, index) { + metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.into_owned())); + } + } + + // Surface `editor:text_frame` for the Text tool's drag cage + if let Some(frame) = source.try_attr::(index) { + metadata.text_frames.entry(element_id).or_insert(frame); + } + } + + // If this item carries a snapshot of upstream graphic content (e.g. it was produced by Boolean Operation, + // Flatten Path, Morph, or any other destructive merge), recurse into that snapshot so the editor can + // surface the original child layers' click targets. + if let Some(upstream_nested_layers) = source.attr::(index).filter(|layers| !layers.is_empty()) { + let mut upstream_footprint = footprint; + upstream_footprint.transform *= transform; + upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None); + } + } + + // Overwrite with the full accumulated set (not just item 0's contribution) + for (element_id, targets) in accumulated_click_targets { + metadata.click_targets.insert(element_id, targets); + } + for (element_id, targets) in accumulated_outlines { + metadata.outlines.insert(element_id, targets); + } +} + +fn add_vector_upstream_click_targets>(source: &S, click_targets: &mut Vec) { + for index in 0..source.lane_count() { + let Some(element) = source.element(index) else { continue }; + let transform: DAffine2 = source.attr::(index); + + // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) + let vector = source.attr::(index).unwrap_or(element); + + extend_targets_from_vector(click_targets, source, index, vector, transform); + } +} + +fn add_vector_upstream_outline_targets>(source: &S, outlines: &mut Vec) { + // Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms + for index in 0..source.lane_count() { + let Some(element) = source.element(index) else { continue }; + let transform: DAffine2 = source.attr::(index); + + extend_targets_from_vector(outlines, source, index, element, transform); + } +} + +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + render_vector_svg(self, render, render_params) } fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - for index in 0..self.len() { - use graphic_types::vector_types::vector; - - let Some(element) = self.element(index) else { continue }; - let item_transform: DAffine2 = self.attr::(index); - let blend_mode_attr: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let multiplied_transform = parent_transform * item_transform; - let has_real_stroke = element.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 mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); - let mut element_transform = set_stroke_transform - .map(|stroke_transform| multiplied_transform * stroke_transform.inverse()) - .unwrap_or(DAffine2::IDENTITY); - if let Some(alignment_transform) = render_params.alignment_parent_transform { - applied_stroke_transform = alignment_transform; - element_transform = if transform_is_invertible(alignment_transform) { - multiplied_transform * alignment_transform.inverse() - } else { - multiplied_transform - }; - } - let layer_bounds = element.bounding_box().unwrap_or_default(); - - let mut path = kurbo::BezPath::new(); - for mut bezpath in element.stroke_bezpath_iter() { - bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - for element in bezpath { - path.push(element); - } - } - - let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL); - let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE); - - // If we're using opacity or a blend mode, we need to push a layer - let blend_mode = match render_params.render_mode { - RenderMode::Outline => peniko::Mix::Normal, - _ => blend_mode_attr.to_peniko(), - }; - let mut layer = false; - - // Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path). - // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since - // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. - let stroke = element.stroke.as_ref(); - let stroke_fully_transparent = stroke_graphic_list.as_ref().is_none_or(|l| l.element(0).is_none_or(|g| g.is_fully_transparent())); - 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()); - - 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; - // `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, - 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), - ); - } - - let use_layer = can_draw_aligned_stroke; - let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); - - let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| { - let Some(fill_graphic) = fill_graphic_list.as_deref() else { return }; - - for paint_index in 0..fill_graphic.len() { - let Some(paint) = fill_graphic.element(paint_index) else { continue }; - match paint { - Graphic::Color(list) => { - let Some(color) = list.element(0) else { continue }; - - let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); - scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); - } - Graphic::Gradient(list) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { - continue; - }; - - let inverse_element_transform = if transform_is_invertible(element_transform) { - element_transform.inverse() - } else { - Default::default() - }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); - scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); - } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Group(_) => { - scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); - paint.render_to_vello(scene, multiplied_transform, context, render_params); - scene.pop_layer(); - } - }; - } - }; - - // Branching vectors without regions (e.g. mesh grids) need face-by-face fill rendering. - let use_face_fill = element.use_face_fill(); - let do_fill = |scene: &mut Scene, context: &mut RenderContext| { - if use_face_fill { - for mut face_path in element.construct_faces().filter(|face| face.area() >= 0.) { - face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - let mut kurbo_path = kurbo::BezPath::new(); - for element in face_path { - kurbo_path.push(element); - } - do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero); - } - } else if element.is_branching() { - do_fill_path(scene, context, &path, peniko::Fill::EvenOdd); - } else { - do_fill_path(scene, context, &path, peniko::Fill::NonZero); - } - }; - - let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| { - let Some(stroke_graphic_list) = stroke_graphic_list.as_deref() else { return }; - let Some(stroke) = stroke else { return }; - - for paint_index in 0..stroke_graphic_list.len() { - let Some(stroke_graphic) = stroke_graphic_list.element(paint_index) else { - continue; - }; - - let cap = match stroke.cap { - StrokeCap::Butt => Cap::Butt, - StrokeCap::Round => Cap::Round, - StrokeCap::Square => Cap::Square, - }; - let join = match stroke.join { - StrokeJoin::Miter => Join::Miter, - StrokeJoin::Bevel => Join::Bevel, - StrokeJoin::Round => Join::Round, - }; - let dash_pattern = stroke.dash_lengths.iter().map(|l| l.max(0.)).collect(); - let stroke = kurbo::Stroke { - width: stroke.weight * width_scale, - miter_limit: stroke.join_miter_limit, - join, - start_cap: cap, - end_cap: cap, - dash_pattern, - dash_offset: stroke.dash_offset, - }; - - if stroke.width <= 0. { - continue; - }; - - match stroke_graphic { - Graphic::Color(list) => { - let Some(color) = list.element(0) else { continue }; - let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); - - scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path); - } - Graphic::Gradient(list) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { - continue; - }; - let inverse_element_transform = if transform_is_invertible(element_transform) { - element_transform.inverse() - } else { - Default::default() - }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); - - scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); - } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Group(_) => { - let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); - - scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); - stroke_graphic.render_to_vello(scene, multiplied_transform, context, render_params); - scene.pop_layer(); - } - }; - } - }; - - // Render the path - match render_params.render_mode { - RenderMode::Outline => { - let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); - - scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path); - } - _ => { - if use_layer { - let mut cloned_element = element.clone(); - cloned_element.stroke = None; - - // The mask must draw at full alpha so `SrcOut` fully zeroes the path interior. - // The outer opacity/blend layer (above) handles the user-set opacity. - let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform); - set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK)); - let vector_list = List::new_from_item(mask_item); - - let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); - // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed - let inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true)); - let (largest_scale, _) = singular_values(applied_stroke_transform); - let quad = Quad::from_box(bounds).inflate(inflation * largest_scale); - let bounds = quad.bounding_box(); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - - let compose = if stroke.is_some_and(|x| x.align == StrokeAlign::Outside) { - peniko::Compose::SrcOut - } else { - peniko::Compose::SrcIn - }; - - if wants_stroke_below { - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform)); - scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - - do_stroke(scene, 2., context); - - scene.pop_layer(); - scene.pop_layer(); - - do_fill(scene, context); - } else { - // Fill first (unclipped), then stroke (clipped) above - do_fill(scene, context); - - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform)); - scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - - do_stroke(scene, 2., context); - - scene.pop_layer(); - scene.pop_layer(); - } - } else { - // Non-aligned strokes or open paths: default order behavior - enum Op { - Fill, - Stroke, - } - - let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) { - true => [Op::Stroke, Op::Fill], - false => [Op::Fill, Op::Stroke], // Default - }; - - for operation in &order { - match operation { - Op::Fill => do_fill(scene, context), - Op::Stroke => do_stroke(scene, 1., context), - } - } - } - } - } - - // If we pushed a layer for opacity or a blend mode, we need to pop it - if layer { - scene.pop_layer(); - } - } + render_vector_vello(self, scene, parent_transform, context, render_params) } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { - // Aggregate all items' targets per element_id so multi-item lists (e.g. 'Text' node with "Separate Glyphs" active) 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.attr::(0) } else { DAffine2::IDENTITY }; - let item_zero_inverse = if transform_is_invertible(item_zero_transform) { - item_zero_transform.inverse() - } else { - DAffine2::IDENTITY - }; - - let mut accumulated_click_targets: HashMap>> = HashMap::new(); - let mut accumulated_outlines: HashMap>> = HashMap::new(); - - for index in 0..self.len() { - let Some(source) = self.element(index) else { continue }; - let transform: DAffine2 = self.attr::(index); - let layer_path: &[NodeId] = self.attr::(index); - let layer = layer_path.last().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); - } - - // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes) - let click_target_vector = self.attr::(index).unwrap_or(source); - - let item_relative_transform = item_zero_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); - accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new)); - - // Outlines always use source geometry so the visual outline reflects actual letterforms - let mut outlines_unwrapped = Vec::new(); - extend_targets_from_vector(&mut outlines_unwrapped, self, index, source, item_relative_transform); - accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new)); - - // Source geometry (not the click-target override) so editing tools work on letterforms. - // Recorded together with `vector_data` from the same (first) row so stroke geometry stays consistent with the paint. - // Only item 0 is recorded since editing tools can only target a single item currently. - // If that row has no paint attribute, none is recorded. - if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) { - e.insert(Arc::new(source.clone())); - - if let Some(fill_graphic) = graphic_list_at(self, index, ATTR_FILL) { - metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.into_owned())); - } - if let Some(stroke_graphic) = graphic_list_at(self, index, ATTR_STROKE) { - metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.into_owned())); - } - } - - // Surface `editor:text_frame` for the Text tool's drag cage - if let Some(frame) = self.try_attr::(index) { - metadata.text_frames.entry(element_id).or_insert(frame); - } - } - - // If this item carries a snapshot of upstream graphic content (e.g. it was produced by Boolean Operation, - // Flatten Path, Morph, or any other destructive merge), recurse into that snapshot so the editor can - // surface the original child layers' click targets. - if let Some(upstream_nested_layers) = self.attr::(index).filter(|layers| !layers.is_empty()) { - let mut upstream_footprint = footprint; - upstream_footprint.transform *= transform; - upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None); - } - } - - // Overwrite with the full accumulated set (not just item 0's contribution) - for (element_id, targets) in accumulated_click_targets { - metadata.click_targets.insert(element_id, targets); - } - for (element_id, targets) in accumulated_outlines { - metadata.outlines.insert(element_id, targets); - } + collect_vector_metadata(self, metadata, footprint, caller_element_id) } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - for index in 0..self.len() { - let Some(source) = self.element(index) else { continue }; - let transform: DAffine2 = self.attr::(index); - - // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) - let vector = self.attr::(index).unwrap_or(source); - - extend_targets_from_vector(click_targets, self, index, vector, transform); - } + add_vector_upstream_click_targets(self, click_targets) } fn add_upstream_outline_targets(&self, outlines: &mut Vec) { - // Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms - for index in 0..self.len() { - let Some(source) = self.element(index) else { continue }; - let transform: DAffine2 = self.attr::(index); - - extend_targets_from_vector(outlines, self, index, source, transform); - } + add_vector_upstream_outline_targets(self, outlines) } fn new_ids_from_hash(&mut self, reference: Option) { @@ -1672,8 +1732,8 @@ impl Render for List { /// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work /// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append. -fn extend_targets_from_vector(targets: &mut Vec, vector_list: &List, index: usize, geometry: &Vector, transform: DAffine2) { - let filled = has_paint_at(vector_list, index, ATTR_FILL); +fn extend_targets_from_vector>(targets: &mut Vec, source: &S, index: usize, geometry: &Vector, transform: DAffine2) { + let filled = has_paint::(source, index); let mut subpaths: Vec> = geometry.stroke_bezier_paths().collect(); let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed()); @@ -1724,267 +1784,267 @@ fn extend_free_point_targets(vector: &Vector, transform: DAffine2) -> impl Itera }) } -impl Render for List> { - fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - for index in 0..self.len() { - let Some(image) = self.element(index) else { continue }; +fn render_raster_cpu_svg>>(source: &S, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..source.lane_count() { + let Some(image) = source.element(index) else { continue }; - let transform: DAffine2 = self.attr::(index); - let blend_mode_attr: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); + let transform: DAffine2 = source.attr::(index); + let blend_mode_attr: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); - if image.data.is_empty() { - continue; - } + if image.data.is_empty() { + continue; + } - if render_params.to_canvas() { - let mut image_copy = image.clone(); - image_copy.data_mut().map_pixels(|p| p.to_unassociated_alpha()); - let id = *render.image_data.entry(CacheHashWrapper(image_copy.into_data())).or_insert_with(generate_uuid); + if render_params.to_canvas() { + let mut image_copy = image.clone(); + image_copy.data_mut().map_pixels(|p| p.to_unassociated_alpha()); + let id = *render.image_data.entry(CacheHashWrapper(image_copy.into_data())).or_insert_with(generate_uuid); - render.parent_tag( - "foreignObject", - |attributes| { - let size = DVec2::new(image.width as f64, image.height as f64); + render.parent_tag( + "foreignObject", + |attributes| { + let size = DVec2::new(image.width as f64, image.height as f64); - let matrix = transform * DAffine2::from_scale(1. / size); - let matrix = format_transform_matrix(matrix); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - - attributes.push("width", size.x.to_string()); - attributes.push("height", size.y.to_string()); - - 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()); - } - }, - |render| { - render.leaf_tag( - "img", // Must be a self-closing (void element) tag, so we can't use `div` or `span`, for example - |attributes| { - attributes.push("data-canvas-placeholder", id.to_string()); - }, - ) - }, - ); - } else { - let base64_string = image.base64_string.clone().unwrap_or_else(|| { - use base64::Engine; - - let output = image.to_png(); - let preamble = "data:image/png;base64,"; - let mut base64_string = String::with_capacity(preamble.len() + output.len() * 4); - base64_string.push_str(preamble); - base64::engine::general_purpose::STANDARD.encode_string(output, &mut base64_string); - base64_string - }); - - render.leaf_tag("image", |attributes| { - attributes.push("width", "1"); - attributes.push("height", "1"); - attributes.push("preserveAspectRatio", "none"); - attributes.push("href", base64_string); - let matrix = format_transform_matrix(transform); + let matrix = transform * DAffine2::from_scale(1. / size); + let matrix = format_transform_matrix(matrix); if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix); } + attributes.push("width", size.x.to_string()); + attributes.push("height", size.y.to_string()); + 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()); } - }); - } - } - } + }, + |render| { + render.leaf_tag( + "img", // Must be a self-closing (void element) tag, so we can't use `div` or `span`, for example + |attributes| { + attributes.push("data-canvas-placeholder", id.to_string()); + }, + ) + }, + ); + } else { + let base64_string = image.base64_string.clone().unwrap_or_else(|| { + use base64::Engine; - fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _: &mut RenderContext, render_params: &RenderParams) { - for index in 0..self.len() { - let Some(image) = self.element(index) else { continue }; - if image.data.is_empty() { - continue; - } + let output = image.to_png(); + let preamble = "data:image/png;base64,"; + let mut base64_string = String::with_capacity(preamble.len() + output.len() * 4); + base64_string.push_str(preamble); + base64::engine::general_purpose::STANDARD.encode_string(output, &mut base64_string); + base64_string + }); - let blend_mode_attr: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let blend_mode = blend_mode_attr.to_peniko(); - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let mut layer = false; - - if (opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default())) - && let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, false) - { - let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); - layer = true; - } - - let transform_attribute: DAffine2 = self.attr::(index); - - if let RenderMode::Outline = render_params.render_mode { - let outline_transform: DAffine2 = transform * transform_attribute; - draw_raster_outline(scene, &outline_transform, render_params); - - if layer { - scene.pop_layer(); + render.leaf_tag("image", |attributes| { + attributes.push("width", "1"); + attributes.push("height", "1"); + attributes.push("preserveAspectRatio", "none"); + attributes.push("href", base64_string); + let matrix = format_transform_matrix(transform); + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix); } - continue; - } + 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()); + } + }); + } + } +} - let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64)); +fn render_raster_cpu_vello> + BoundingBox>(source: &S, scene: &mut Scene, transform: DAffine2, render_params: &RenderParams) { + for index in 0..source.lane_count() { + let Some(image) = source.element(index) else { continue }; + if image.data.is_empty() { + continue; + } - let image_brush = peniko::ImageBrush::new(peniko::ImageData { - data: image.to_flat_u8().0.into(), - format: peniko::ImageFormat::Rgba8, - width: image.width, - height: image.height, - alpha_type: peniko::ImageAlphaType::Alpha, - }) - .with_extend(peniko::Extend::Repeat); + let blend_mode_attr: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let blend_mode = blend_mode_attr.to_peniko(); - scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array())); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let mut layer = false; + + if (opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default())) + && let RenderBoundingBox::Rectangle(bounds) = source.bounding_box(transform, false) + { + let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); + layer = true; + } + + let transform_attribute: DAffine2 = source.attr::(index); + + if let RenderMode::Outline = render_params.render_mode { + let outline_transform: DAffine2 = transform * transform_attribute; + draw_raster_outline(scene, &outline_transform, render_params); if layer { scene.pop_layer(); } + + continue; } + + let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64)); + + let image_brush = peniko::ImageBrush::new(peniko::ImageData { + data: image.to_flat_u8().0.into(), + format: peniko::ImageFormat::Rgba8, + width: image.width, + height: image.height, + alpha_type: peniko::ImageAlphaType::Alpha, + }) + .with_extend(peniko::Extend::Repeat); + + scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array())); + + if layer { + scene.pop_layer(); + } + } +} + +fn collect_raster_metadata(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { + let Some(element_id) = element_id else { return }; + let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); + + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); + metadata.upstream_footprints.insert(element_id, footprint); + // TODO: Find a way to handle more than one item of the `List>` + if source.lane_count() > 0 { + let transform: DAffine2 = source.attr::(0); + metadata.local_transforms.insert(element_id, transform); + + // If this raster carries a snapshot of upstream graphic content (e.g. it was produced by Rasterize, + // which destructively merges its inputs into pixels), recurse into that snapshot so the editor can + // surface the original child layers' click targets (the same mechanism Boolean Operation uses). + // The snapshot was captured before Rasterize shifted its input transforms to align with the rasterization + // area, so the children are already in the coordinate space matching `footprint` here — we must NOT + // multiply in `transform` (which is the rasterization area, not a layer-stack transform). + if let Some(upstream_nested_layers) = source.attr::(0).filter(|layers| !layers.is_empty()) { + upstream_nested_layers.collect_metadata(metadata, footprint, None); + } + } +} + +fn add_raster_upstream_click_targets(click_targets: &mut Vec) { + let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); + click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); +} + +impl Render for List> { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + render_raster_cpu_svg(self, render, render_params) + } + + fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _: &mut RenderContext, render_params: &RenderParams) { + render_raster_cpu_vello(self, scene, transform, render_params) } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { - let Some(element_id) = element_id else { return }; - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - - metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); - metadata.upstream_footprints.insert(element_id, footprint); - // TODO: Find a way to handle more than one item of the `List>` - if !self.is_empty() { - let transform: DAffine2 = self.attr::(0); - metadata.local_transforms.insert(element_id, transform); - - // If this raster carries a snapshot of upstream graphic content (e.g. it was produced by Rasterize, - // which destructively merges its inputs into pixels), recurse into that snapshot so the editor can - // surface the original child layers' click targets (the same mechanism Boolean Operation uses). - // The snapshot was captured before Rasterize shifted its input transforms to align with the rasterization - // area, so the children are already in the coordinate space matching `footprint` here — we must NOT - // multiply in `transform` (which is the rasterization area, not a layer-stack transform). - if let Some(upstream_nested_layers) = self.attr::(0).filter(|layers| !layers.is_empty()) { - upstream_nested_layers.collect_metadata(metadata, footprint, None); - } - } + collect_raster_metadata(self, metadata, footprint, element_id) } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); + add_raster_upstream_click_targets(click_targets) } } static LAZY_ARC_VEC_ZERO_U8: LazyLock>> = LazyLock::new(|| Arc::new(Vec::new())); +fn render_raster_gpu_vello> + BoundingBox>(source: &S, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + for index in 0..source.lane_count() { + let Some(raster) = source.element(index) else { continue }; + let blend_mode_attr: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let clip_attr: bool = source.attr::(index); + let blend_mode = match render_params.render_mode { + RenderMode::Outline => peniko::Mix::Normal, + _ => blend_mode_attr.to_peniko(), + }; + + let mut layer = false; + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let any_nondefault = blend_mode_attr != BlendMode::default() || opacity < 1. || clip_attr; + if (render_params.render_mode != RenderMode::Outline && any_nondefault) + && let RenderBoundingBox::Rectangle(bounds) = source.bounding_box(transform, true) + { + let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); + layer = true; + } + + let transform_attribute: DAffine2 = source.attr::(index); + + if let RenderMode::Outline = render_params.render_mode { + let outline_transform = transform * transform_attribute; + draw_raster_outline(scene, &outline_transform, render_params); + + if layer { + scene.pop_layer(); + } + + continue; + } + + let width = raster.data().width(); + let height = raster.data().height(); + let image = peniko::ImageBrush::new(peniko::ImageData { + data: peniko::Blob::new(LAZY_ARC_VEC_ZERO_U8.deref().clone()), + format: peniko::ImageFormat::Rgba8, + width, + height, + alpha_type: peniko::ImageAlphaType::Alpha, + }) + .with_extend(peniko::Extend::Repeat); + let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64)); + scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array())); + context.resource_overrides.push((image, raster.texture.clone())); + + if layer { + scene.pop_layer() + } + } +} + impl Render for List> { fn render_svg(&self, _render: &mut SvgRender, _render_params: &RenderParams) { log::warn!("tried to render texture as an svg"); } fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - for index in 0..self.len() { - let Some(raster) = self.element(index) else { continue }; - let blend_mode_attr: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let clip_attr: bool = self.attr::(index); - let blend_mode = match render_params.render_mode { - RenderMode::Outline => peniko::Mix::Normal, - _ => blend_mode_attr.to_peniko(), - }; - - let mut layer = false; - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let any_nondefault = blend_mode_attr != BlendMode::default() || opacity < 1. || clip_attr; - if (render_params.render_mode != RenderMode::Outline && any_nondefault) - && let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, true) - { - let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); - layer = true; - } - - let transform_attribute: DAffine2 = self.attr::(index); - - if let RenderMode::Outline = render_params.render_mode { - let outline_transform = transform * transform_attribute; - draw_raster_outline(scene, &outline_transform, render_params); - - if layer { - scene.pop_layer(); - } - - continue; - } - - let width = raster.data().width(); - let height = raster.data().height(); - let image = peniko::ImageBrush::new(peniko::ImageData { - data: peniko::Blob::new(LAZY_ARC_VEC_ZERO_U8.deref().clone()), - format: peniko::ImageFormat::Rgba8, - width, - height, - alpha_type: peniko::ImageAlphaType::Alpha, - }) - .with_extend(peniko::Extend::Repeat); - let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64)); - scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array())); - context.resource_overrides.push((image, raster.texture.clone())); - - if layer { - scene.pop_layer() - } - } + render_raster_gpu_vello(self, scene, transform, context, render_params) } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { - let Some(element_id) = element_id else { return }; - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - - metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); - metadata.upstream_footprints.insert(element_id, footprint); - // TODO: Find a way to handle more than one item of the `List>` - if !self.is_empty() { - let transform: DAffine2 = self.attr::(0); - metadata.local_transforms.insert(element_id, transform); - - // If this raster carries a snapshot of upstream graphic content (e.g. it was produced by Rasterize, - // which destructively merges its inputs into pixels), recurse into that snapshot so the editor can - // surface the original child layers' click targets (the same mechanism Boolean Operation uses). - // The snapshot was captured before Rasterize shifted its input transforms to align with the rasterization - // area, so the children are already in the coordinate space matching `footprint` here — we must NOT - // multiply in `transform` (which is the rasterization area, not a layer-stack transform). - if let Some(upstream_nested_layers) = self.attr::(0).filter(|layers| !layers.is_empty()) { - upstream_nested_layers.collect_metadata(metadata, footprint, None); - } - } + collect_raster_metadata(self, metadata, footprint, element_id) } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); + add_raster_upstream_click_targets(click_targets) } } @@ -2068,181 +2128,189 @@ impl Render for List { } } -impl Render for List { - fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - // For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`. - // The viewBox crops the overshoot. Canvas rendering keeps the polyline path since Chrome rejects rects larger than ~20 million. - let thumbnail_rect = if render_params.thumbnail { - let truncated_size = render_params.footprint.resolution.as_dvec2(); - let margin = DVec2::ONE; - Some((render_params.footprint.transform.translation - margin / 2., truncated_size + margin)) - } else { - None +fn render_gradient_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) { + // For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`. + // The viewBox crops the overshoot. Canvas rendering keeps the polyline path since Chrome rejects rects larger than ~20 million. + let thumbnail_rect = if render_params.thumbnail { + let truncated_size = render_params.footprint.resolution.as_dvec2(); + let margin = DVec2::ONE; + Some((render_params.footprint.transform.translation - margin / 2., truncated_size + margin)) + } else { + None + }; + + for index in 0..source.lane_count() { + let Some(gradient) = source.element(index) else { continue }; + let transform: DAffine2 = source.attr::(index); + let blend_mode: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let spread_method: GradientSpreadMethod = source.attr::(index); + let gradient_type: GradientType = source.attr::(index); + let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; + render.leaf_tag(tag, |attributes| { + if let Some((min, size)) = thumbnail_rect { + attributes.push("x", min.x.to_string()); + attributes.push("y", min.y.to_string()); + attributes.push("width", size.x.to_string()); + attributes.push("height", size.y.to_string()); + } else { + // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses + // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning. + // 1e7 stays under that limit while still being far larger than any practical document extent. + const MAX: f64 = 1e7; + attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); + } + + let mut stop_string = String::new(); + for (position, color, original_midpoint) in gradient.interpolated_samples() { + let _ = write!(stop_string, r##""); + } + + // render_thumbnail already added the footprint transform + let gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform }; + let gradient_transform_matrix = format_transform_matrix(gradient_transform); + let gradient_transform_attribute = if gradient_transform_matrix.is_empty() { + String::new() + } else { + format!(r#" gradientTransform="{gradient_transform_matrix}""#) + }; + + let gradient_id = generate_uuid(); + let spread_method_attribute = if spread_method == GradientSpreadMethod::Pad { + String::new() + } else { + format!(r#" spreadMethod="{}""#, spread_method.svg_name()) + }; + + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied + match gradient_type { + GradientType::Linear => { + let _ = write!( + &mut attributes.0.svg_defs, + r#"{stop_string}"# + ); + } + GradientType::Radial => { + let _ = write!( + &mut attributes.0.svg_defs, + r#"{stop_string}"# + ); + } + } + + attributes.push("fill", format!("url('#{gradient_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()); + } + + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + }); + } +} + +fn render_gradient_vello>(source: &S, scene: &mut Scene, parent_transform: DAffine2, render_params: &RenderParams) { + use vello::peniko; + + if let RenderMode::Outline = render_params.render_mode { + return; + } + + for index in 0..source.lane_count() { + let Some(gradient) = source.element(index) else { continue }; + let spread_method: GradientSpreadMethod = source.attr::(index); + let gradient_type: GradientType = source.attr::(index); + let transform: DAffine2 = source.attr::(index); + let blend_mode_attr: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let gradient_transform = parent_transform * transform; + + let blend_mode = blend_mode_attr.to_peniko(); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let mut stops: peniko::ColorStops = peniko::ColorStops::new(); + for (position, color, _) in gradient.interpolated_samples() { + stops.push(peniko::ColorStop { + offset: position as f32, + color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), + }) + } + + let extend = match spread_method { + GradientSpreadMethod::Pad => peniko::Extend::Pad, + GradientSpreadMethod::Reflect => peniko::Extend::Reflect, + GradientSpreadMethod::Repeat => peniko::Extend::Repeat, }; - for index in 0..self.len() { - let Some(gradient) = self.element(index) else { continue }; - let transform: DAffine2 = self.attr::(index); - let blend_mode: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let spread_method: GradientSpreadMethod = self.attr::(index); - let gradient_type: GradientType = self.attr::(index); - let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; - render.leaf_tag(tag, |attributes| { - if let Some((min, size)) = thumbnail_rect { - attributes.push("x", min.x.to_string()); - attributes.push("y", min.y.to_string()); - attributes.push("width", size.x.to_string()); - attributes.push("height", size.y.to_string()); - } else { - // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses - // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning. - // 1e7 stays under that limit while still being far larger than any practical document extent. - const MAX: f64 = 1e7; - attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); - } + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. + // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. + let kind = match gradient_type { + GradientType::Linear => peniko::LinearGradientPosition { + start: to_point(DVec2::ZERO), + end: to_point(DVec2::X), + } + .into(), + GradientType::Radial => peniko::RadialGradientPosition { + start_center: to_point(DVec2::ZERO), + start_radius: 0., + end_center: to_point(DVec2::ZERO), + end_radius: 1., + } + .into(), + }; - let mut stop_string = String::new(); - for (position, color, original_midpoint) in gradient.interpolated_samples() { - let _ = write!(stop_string, r##""); - } + let fill = peniko::Brush::Gradient(peniko::Gradient { + kind, + stops, + extend, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, + ..Default::default() + }); + let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_type).to_cols_array()); + let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); - // render_thumbnail already added the footprint transform - let gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform }; - let gradient_transform_matrix = format_transform_matrix(gradient_transform); - let gradient_transform_attribute = if gradient_transform_matrix.is_empty() { - String::new() - } else { - format!(r#" gradientTransform="{gradient_transform_matrix}""#) - }; - - let gradient_id = generate_uuid(); - let spread_method_attribute = if spread_method == GradientSpreadMethod::Pad { - String::new() - } else { - format!(r#" spreadMethod="{}""#, spread_method.svg_name()) - }; - - // The unit gradient line is the +X unit vector in local space, before the item's transform is applied - match gradient_type { - GradientType::Linear => { - let _ = write!( - &mut attributes.0.svg_defs, - r#"{stop_string}"# - ); - } - GradientType::Radial => { - let _ = write!( - &mut attributes.0.svg_defs, - r#"{stop_string}"# - ); - } - } - - attributes.push("fill", format!("url('#{gradient_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()); - } - - if blend_mode != BlendMode::default() { - attributes.push("style", blend_mode.render()); - } - }); + let mut layer = false; + if opacity < 1. || blend_mode_attr != BlendMode::default() { + let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); + // See implementation in `List` for more detail + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect); + layer = true; } + + // Encode shape and brush manually instead of Scene.fill(), which would multiply brush_transform by the path transform + scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&kurbo::Affine::scale(f64::INFINITY))); + scene.encoding_mut().encode_fill_style(peniko::Fill::NonZero); + scene.encoding_mut().encode_shape(&rect, true); + + scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&brush_transform)); + scene.encoding_mut().swap_last_path_tags(); + scene.encoding_mut().encode_brush(&fill, 1.); + + if layer { + scene.pop_layer(); + } + } +} + +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + render_gradient_svg(self, render, render_params) } fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - use vello::peniko; - - if let RenderMode::Outline = render_params.render_mode { - return; - } - - for index in 0..self.len() { - let Some(gradient) = self.element(index) else { continue }; - let spread_method: GradientSpreadMethod = self.attr::(index); - let gradient_type: GradientType = self.attr::(index); - let transform: DAffine2 = self.attr::(index); - let blend_mode_attr: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let gradient_transform = parent_transform * transform; - - let blend_mode = blend_mode_attr.to_peniko(); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - - let mut stops: peniko::ColorStops = peniko::ColorStops::new(); - for (position, color, _) in gradient.interpolated_samples() { - stops.push(peniko::ColorStop { - offset: position as f32, - color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), - }) - } - - let extend = match spread_method { - GradientSpreadMethod::Pad => peniko::Extend::Pad, - GradientSpreadMethod::Reflect => peniko::Extend::Reflect, - GradientSpreadMethod::Repeat => peniko::Extend::Repeat, - }; - - // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. - // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. - let kind = match gradient_type { - GradientType::Linear => peniko::LinearGradientPosition { - start: to_point(DVec2::ZERO), - end: to_point(DVec2::X), - } - .into(), - GradientType::Radial => peniko::RadialGradientPosition { - start_center: to_point(DVec2::ZERO), - start_radius: 0., - end_center: to_point(DVec2::ZERO), - end_radius: 1., - } - .into(), - }; - - let fill = peniko::Brush::Gradient(peniko::Gradient { - kind, - stops, - extend, - interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, - ..Default::default() - }); - let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_type).to_cols_array()); - let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); - - let mut layer = false; - if opacity < 1. || blend_mode_attr != BlendMode::default() { - let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - // See implementation in `List` for more detail - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect); - layer = true; - } - - // Encode shape and brush manually instead of Scene.fill(), which would multiply brush_transform by the path transform - scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&kurbo::Affine::scale(f64::INFINITY))); - scene.encoding_mut().encode_fill_style(peniko::Fill::NonZero); - scene.encoding_mut().encode_shape(&rect, true); - - scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&brush_transform)); - scene.encoding_mut().swap_last_path_tags(); - scene.encoding_mut().encode_brush(&fill, 1.); - - if layer { - scene.pop_layer(); - } - } + render_gradient_vello(self, scene, parent_transform, render_params) } } @@ -2317,22 +2385,22 @@ fn draw_glyph_run_to_bezpaths(glyph_run: &parley::GlyphRun<'_, ()>, x_offset: f3 } } -/// Lays out text item `index` of a styled `List` and returns its local size and transform. The `BoundingBox` trait can't do +/// Lays out the text item at `index` and returns its local size and transform. The `BoundingBox` trait can't do /// this since a bare `String` carries no typography, so click-target and bounding-box computation share this. Falls back to an em /// square if the font isn't registered yet. -fn text_item_size_and_transform(list: &List, index: usize) -> Option<(DVec2, DAffine2)> { - let text = list.element(index)?; +fn text_item_size_and_transform>(source: &S, index: usize) -> Option<(DVec2, DAffine2)> { + let text = source.element(index)?; let font: Resource = { - let f = list.attr::(index); + let f = source.attr::(index); if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f.clone() } }; - let font_size: f64 = list.attr::(index); - let line_height: f64 = list.attr::(index); - let letter_spacing: f64 = list.attr::(index); - let max_width: Option = list.attr::(index); - let max_height: Option = list.attr::(index); - let align: text_nodes::TextAlign = list.attr::(index); - let transform: DAffine2 = list.attr::(index); + let font_size: f64 = source.attr::(index); + let line_height: f64 = source.attr::(index); + let letter_spacing: f64 = source.attr::(index); + let max_width: Option = source.attr::(index); + let max_height: Option = source.attr::(index); + let align: text_nodes::TextAlign = source.attr::(index); + let transform: DAffine2 = source.attr::(index); let typesetting = text_nodes::TypesettingConfig { font_size, @@ -2356,12 +2424,12 @@ fn text_item_size_and_transform(list: &List, index: usize) -> Option<(DV Some((DVec2::new(width, height), transform)) } -/// Union bounding box of a styled `List`, laid out per item. The `BoundingBox` trait returns `None` for `List` +/// Union bounding box of a styled text source, laid out per item. The `BoundingBox` trait returns `None` for `List` /// (a bare `String` has no extent), so text-layer thumbnails and bounds use this instead. Each item is laid out under `outer_transform`. -pub fn text_list_bounding_box(list: &List, outer_transform: DAffine2) -> RenderBoundingBox { +pub fn text_list_bounding_box>(source: &S, outer_transform: DAffine2) -> RenderBoundingBox { let mut bounds: Option<[DVec2; 2]> = None; - for index in 0..list.len() { - let Some((size, transform)) = text_item_size_and_transform(list, index) else { continue }; + for index in 0..source.lane_count() { + let Some((size, transform)) = text_item_size_and_transform(source, index) else { continue }; let full_transform = outer_transform * transform; for corner in [DVec2::ZERO, DVec2::new(size.x, 0.), DVec2::new(0., size.y), size] { let point = full_transform.transform_point2(corner); @@ -2379,13 +2447,13 @@ pub fn text_list_bounding_box(list: &List, outer_transform: DAffine2) -> /// Like `List::thumbnail_bounding_box`, but lays out `Graphic::Text` items, which the `BoundingBox` trait reports as `None`. /// Used for layer thumbnails so text layers (whose content is a `List` wrapping the text) frame their content. -pub fn graphic_list_bounding_box(list: &List, transform: DAffine2) -> RenderBoundingBox { +pub fn graphic_list_bounding_box>(source: &S, transform: DAffine2) -> RenderBoundingBox { let mut combined: Option<[DVec2; 2]> = None; let mut any_infinite = false; - for index in 0..list.len() { - let item_transform = transform * list.attr::(index); - let Some(graphic) = list.element(index) else { continue }; + for index in 0..source.lane_count() { + let item_transform = transform * source.attr::(index); + let Some(graphic) = source.element(index) else { continue }; let bounds = match graphic { Graphic::Text(text_list) => text_list_bounding_box(text_list, item_transform), Graphic::Graphic(sub_list) => graphic_list_bounding_box(sub_list, item_transform), @@ -2410,205 +2478,221 @@ pub fn graphic_list_bounding_box(list: &List, transform: DAffine2) -> R } } -impl Render for List { - fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - for index in 0..self.len() { - let Some(text) = self.element(index) else { continue }; - if text.is_empty() { - continue; +fn render_text_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..source.lane_count() { + let Some(text) = source.element(index) else { continue }; + if text.is_empty() { + continue; + } + + let transform: DAffine2 = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let blend_mode_attr: BlendMode = source.attr::(index); + let font: Resource = { + let f = source.attr::(index); + if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f.clone() } + }; + let font_size: f64 = source.attr::(index); + let line_height: f64 = source.attr::(index); + let letter_spacing: f64 = source.attr::(index); + let max_width: Option = source.attr::(index); + let max_height: Option = source.attr::(index); + let letter_tilt: f64 = source.attr::(index); + let align: text_nodes::TextAlign = source.attr::(index); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let typesetting = text_nodes::TypesettingConfig { + font_size, + line_height_ratio: line_height, + letter_spacing, + letter_tilt, + max_width, + max_height, + align, + }; + + let mut glyph_paths: Vec = Vec::new(); + + text_nodes::TextContext::with_thread_local(|ctx| { + let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; + let tilt_tan = letter_tilt.to_radians().tan(); + + text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { + draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { + glyph_paths.push(bez_path.to_svg()); + }); + }); + }); + + if glyph_paths.is_empty() { + continue; + } + + // Wrap all glyph elements in a with the item's transform/opacity/blend-mode. + render.parent_tag( + "g", + |attributes| { + let matrix = format_transform_matrix(transform); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + if blend_mode_attr != BlendMode::default() { + attributes.push("style", blend_mode_attr.render()); + } + }, + |render| { + for path_d in glyph_paths { + render.leaf_tag("path", |attributes| { + attributes.push("d", path_d); + if let RenderMode::Outline = render_params.render_mode { + attributes.push("fill", "none"); + attributes.push("stroke", "black"); + attributes.push("stroke-width", "1"); + } else { + attributes.push("fill", "black"); + attributes.push("fill-rule", "nonzero"); + } + }); + } + }, + ); + } +} + +fn render_text_vello>(source: &S, scene: &mut Scene, transform: DAffine2, render_params: &RenderParams) { + for index in 0..source.lane_count() { + let Some(text) = source.element(index) else { continue }; + if text.is_empty() { + continue; + } + + let item_transform: DAffine2 = source.attr::(index); + let font: Resource = { + let f = source.attr::(index); + if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f.clone() } + }; + let font_size: f64 = source.attr::(index); + let line_height: f64 = source.attr::(index); + let letter_spacing: f64 = source.attr::(index); + let max_width: Option = source.attr::(index); + let max_height: Option = source.attr::(index); + let letter_tilt: f64 = source.attr::(index); + let align: text_nodes::TextAlign = source.attr::(index); + let blend_mode_attr: BlendMode = source.attr::(index); + let opacity_attr: f64 = source.attr::(index); + let opacity_fill_attr: f64 = source.attr::(index); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let typesetting = text_nodes::TypesettingConfig { + font_size, + line_height_ratio: line_height, + letter_spacing, + letter_tilt, + max_width, + max_height, + align, + }; + + let affine = Affine::new((transform * item_transform).to_cols_array()); + + text_nodes::TextContext::with_thread_local(|ctx| { + let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; + + let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); + if needs_layer { + let alignment_width = max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width()); + let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); + let padding = font_size; + let bounds = kurbo::Rect::new(-padding, -padding, alignment_width as f64 + padding, layout.height() as f64 + padding); + let transformed_bounds = affine.transform_rect_bbox(bounds); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds); } - let transform: DAffine2 = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let blend_mode_attr: BlendMode = self.attr::(index); - let font: Resource = { - let f = self.attr::(index); - if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f.clone() } - }; - let font_size: f64 = self.attr::(index); - let line_height: f64 = self.attr::(index); - let letter_spacing: f64 = self.attr::(index); - let max_width: Option = self.attr::(index); - let max_height: Option = self.attr::(index); - let letter_tilt: f64 = self.attr::(index); - let align: text_nodes::TextAlign = self.attr::(index); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let tilt_tan = letter_tilt.to_radians().tan(); - let typesetting = text_nodes::TypesettingConfig { - font_size, - line_height_ratio: line_height, - letter_spacing, - letter_tilt, - max_width, - max_height, - align, - }; - - let mut glyph_paths: Vec = Vec::new(); - - text_nodes::TextContext::with_thread_local(|ctx| { - let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; - let tilt_tan = letter_tilt.to_radians().tan(); - - text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { - draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { - glyph_paths.push(bez_path.to_svg()); - }); + text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { + draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { + if let RenderMode::Outline = render_params.render_mode { + let (outline_stroke, outline_color) = get_outline_styles(render_params); + scene.stroke(&outline_stroke, affine, outline_color, None, bez_path); + } else { + scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, bez_path); + } }); }); - if glyph_paths.is_empty() { - continue; + if needs_layer { + scene.pop_layer(); } + }); + } +} - // Wrap all glyph elements in a with the item's transform/opacity/blend-mode. - render.parent_tag( - "g", - |attributes| { - let matrix = format_transform_matrix(transform); - if !matrix.is_empty() { - attributes.push("transform", matrix); - } - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } - if blend_mode_attr != BlendMode::default() { - attributes.push("style", blend_mode_attr.render()); - } - }, - |render| { - for path_d in glyph_paths { - render.leaf_tag("path", |attributes| { - attributes.push("d", path_d); - if let RenderMode::Outline = render_params.render_mode { - attributes.push("fill", "none"); - attributes.push("stroke", "black"); - attributes.push("stroke-width", "1"); - } else { - attributes.push("fill", "black"); - attributes.push("fill-rule", "nonzero"); - } - }); - } - }, - ); +fn collect_text_metadata>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { + // Click targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]`. + let item_zero_transform: DAffine2 = if source.lane_count() > 0 { source.attr::(0) } else { DAffine2::IDENTITY }; + let item_zero_inverse = if item_zero_transform.matrix2.determinant() != 0. { + item_zero_transform.inverse() + } else { + DAffine2::IDENTITY + }; + + let mut accumulated_click_targets: HashMap>> = HashMap::new(); + + for index in 0..source.lane_count() { + let layer_path: &[NodeId] = source.attr::(index); + let layer = layer_path.last().copied(); + let Some(element_id) = caller_element_id.or(layer) else { continue }; + + // When recovering element_id from the item's tag (caller passed None), also store the transform metadata. + 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 Some((size, item_transform)) = text_item_size_and_transform(source, index) else { continue }; + let subpath = Subpath::new_rectangle(DVec2::ZERO, size); + let mut target = ClickTarget::new_with_subpath(subpath, 0.); + target.apply_transform(item_zero_inverse * item_transform); + accumulated_click_targets.entry(element_id).or_default().push(Arc::new(target)); + } + + // One rectangle per text item, reused for the selection outline (there's no letterform geometry to outline at this stage). + for (element_id, targets) in accumulated_click_targets { + metadata.outlines.insert(element_id, targets.clone()); + metadata.click_targets.insert(element_id, targets); + } +} + +fn add_text_upstream_click_targets>(source: &S, click_targets: &mut Vec) { + for index in 0..source.lane_count() { + let Some((size, transform)) = text_item_size_and_transform(source, index) else { continue }; + let subpath = Subpath::new_rectangle(DVec2::ZERO, size); + let mut target = ClickTarget::new_with_subpath(subpath, 0.); + target.apply_transform(transform); + click_targets.push(target); + } +} + +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + render_text_svg(self, render, render_params) } fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - for index in 0..self.len() { - let Some(text) = self.element(index) else { continue }; - if text.is_empty() { - continue; - } - - let item_transform: DAffine2 = self.attr::(index); - let font: Resource = { - let f = self.attr::(index); - if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f.clone() } - }; - let font_size: f64 = self.attr::(index); - let line_height: f64 = self.attr::(index); - let letter_spacing: f64 = self.attr::(index); - let max_width: Option = self.attr::(index); - let max_height: Option = self.attr::(index); - let letter_tilt: f64 = self.attr::(index); - let align: text_nodes::TextAlign = self.attr::(index); - let blend_mode_attr: BlendMode = self.attr::(index); - let opacity_attr: f64 = self.attr::(index); - let opacity_fill_attr: f64 = self.attr::(index); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - - let typesetting = text_nodes::TypesettingConfig { - font_size, - line_height_ratio: line_height, - letter_spacing, - letter_tilt, - max_width, - max_height, - align, - }; - - let affine = Affine::new((transform * item_transform).to_cols_array()); - - text_nodes::TextContext::with_thread_local(|ctx| { - let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; - - let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); - if needs_layer { - let alignment_width = max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width()); - let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); - let padding = font_size; - let bounds = kurbo::Rect::new(-padding, -padding, alignment_width as f64 + padding, layout.height() as f64 + padding); - let transformed_bounds = affine.transform_rect_bbox(bounds); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds); - } - - let tilt_tan = letter_tilt.to_radians().tan(); - - text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { - draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { - if let RenderMode::Outline = render_params.render_mode { - let (outline_stroke, outline_color) = get_outline_styles(render_params); - scene.stroke(&outline_stroke, affine, outline_color, None, bez_path); - } else { - scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, bez_path); - } - }); - }); - - if needs_layer { - scene.pop_layer(); - } - }); - } + render_text_vello(self, scene, transform, render_params) } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { - // Click targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]`. - let item_zero_transform: DAffine2 = if !self.is_empty() { self.attr::(0) } else { DAffine2::IDENTITY }; - let item_zero_inverse = if item_zero_transform.matrix2.determinant() != 0. { - item_zero_transform.inverse() - } else { - DAffine2::IDENTITY - }; - - let mut accumulated_click_targets: HashMap>> = HashMap::new(); - - for index in 0..self.len() { - let layer_path: &[NodeId] = self.attr::(index); - let layer = layer_path.last().copied(); - let Some(element_id) = caller_element_id.or(layer) else { continue }; - - // When recovering element_id from the item's tag (caller passed None), also store the transform metadata. - 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 Some((size, item_transform)) = text_item_size_and_transform(self, index) else { continue }; - let subpath = Subpath::new_rectangle(DVec2::ZERO, size); - let mut target = ClickTarget::new_with_subpath(subpath, 0.); - target.apply_transform(item_zero_inverse * item_transform); - accumulated_click_targets.entry(element_id).or_default().push(Arc::new(target)); - } - - // One rectangle per text item, reused for the selection outline (there's no letterform geometry to outline at this stage). - for (element_id, targets) in accumulated_click_targets { - metadata.outlines.insert(element_id, targets.clone()); - metadata.click_targets.insert(element_id, targets); - } + collect_text_metadata(self, metadata, footprint, caller_element_id) } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - for index in 0..self.len() { - let Some((size, transform)) = text_item_size_and_transform(self, index) else { continue }; - let subpath = Subpath::new_rectangle(DVec2::ZERO, size); - let mut target = ClickTarget::new_with_subpath(subpath, 0.); - target.apply_transform(transform); - click_targets.push(target); - } + add_text_upstream_click_targets(self, click_targets) } }