use crate::render_ext::{PaintTarget, RenderExt}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use core_types::CacheHash; use core_types::blending::BlendMode; use core_types::bounds::BoundingBox; use core_types::bounds::RenderBoundingBox; use core_types::color::Color; use core_types::color::SRGBA8; use core_types::consts::DEFAULT_FONT_SIZE; use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, NodeIdPath}; use core_types::math::quad::Quad; use core_types::render_complexity::RenderComplexity; use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_FORM, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; 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::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture}; use graphic_types::vector_types::gradient::{Gradient, GradientForm}; 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::{Artboard, Graphic, Vector}; use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; use skrifa::raw::FontRef as SkrifaFontRef; use skrifa::{GlyphId, MetadataProvider}; use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, LazyLock}; use vector_types::gradient::{GradientSettings, GradientSpread}; use vello::*; #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] enum MaskType { Clip, Mask, } impl MaskType { fn to_attribute(self) -> String { match self { Self::Mask => "mask".to_string(), Self::Clip => "clip-path".to_string(), } } fn write_to_defs(self, svg_defs: &mut String, uuid: u64, svg_string: String) { let id = format!("mask-{uuid}"); match self { Self::Clip => write!(svg_defs, r##"{svg_string}"##).unwrap(), Self::Mask => write!(svg_defs, r##"{svg_string}"##).unwrap(), } } } /// Mutable state used whilst rendering to an SVG pub struct SvgRender { pub svg: Vec, pub svg_defs: String, pub transform: DAffine2, pub image_data: HashMap>, u64>, indent: usize, } impl SvgRender { pub fn new() -> Self { Self { svg: Vec::default(), svg_defs: String::new(), transform: DAffine2::IDENTITY, image_data: HashMap::new(), indent: 0, } } pub fn indent(&mut self) { self.svg.push("\n".into()); self.svg.push("\t".repeat(self.indent).into()); } /// Add an outer `...` tag with a `viewBox` and the `` pub fn format_svg(&mut self, bounds_min: DVec2, bounds_max: DVec2) { let (x, y) = bounds_min.into(); let (size_x, size_y) = (bounds_max - bounds_min).into(); let svg_header = format!( r#"{defs}"#, defs = &self.svg_defs ); self.svg_defs = String::new(); self.svg.insert(0, svg_header.into()); self.svg.push("".into()); } /// Wraps the SVG with `...`, which allows for rotation pub fn wrap_with_transform(&mut self, transform: DAffine2, size: Option) { let view_box = size .map(|size| format!("viewBox=\"0 0 {} {}\" width=\"{}\" height=\"{}\"", size.x, size.y, size.x, size.y)) .unwrap_or_default(); let matrix = format_transform_matrix(transform); let transform = if matrix.is_empty() { String::new() } else { format!(r#" transform="{matrix}""#) }; let svg_header = format!( r#"{defs}"#, defs = &self.svg_defs ); self.svg_defs = String::new(); self.svg.insert(0, svg_header.into()); self.svg.push("".into()); } pub fn leaf_tag(&mut self, name: impl Into, attributes: impl FnOnce(&mut SvgRenderAttrs)) { self.indent(); self.svg.push("<".into()); self.svg.push(name.into()); attributes(&mut SvgRenderAttrs(self)); self.svg.push("/>".into()); } pub fn leaf_node(&mut self, content: impl Into) { self.indent(); self.svg.push(content.into()); } pub fn parent_tag(&mut self, name: impl Into, attributes: impl FnOnce(&mut SvgRenderAttrs), inner: impl FnOnce(&mut Self)) { let name = name.into(); self.indent(); self.svg.push("<".into()); self.svg.push(name.clone()); // Wraps `self` in a newtype (1-tuple) which is then mutated by the `attributes` closure attributes(&mut SvgRenderAttrs(self)); self.svg.push(">".into()); let length = self.svg.len(); self.indent += 1; inner(self); self.indent -= 1; if self.svg.len() != length { self.indent(); self.svg.push("".into()); } else { self.svg.pop(); self.svg.push("/>".into()); } } } pub struct SvgRenderOutput { pub svg: String, pub svg_defs: String, pub image_data: HashMap>, u64>, } impl From<&SvgRenderOutput> for SvgRender { fn from(value: &SvgRenderOutput) -> Self { Self { svg: vec![value.svg.clone().into()], svg_defs: value.svg_defs.clone(), transform: DAffine2::IDENTITY, image_data: value.image_data.clone(), indent: 0, } } } impl From for SvgRenderOutput { fn from(val: SvgRender) -> Self { Self { svg: val.svg.to_svg_string(), svg_defs: val.svg_defs, image_data: val.image_data, } } } impl Default for SvgRender { fn default() -> Self { Self::new() } } #[derive(Clone, Debug, Default)] pub struct RenderContext { pub resource_overrides: Vec<(peniko::ImageBrush, Texture)>, } #[derive(Default, Clone, Copy, Hash, graphene_hash::CacheHash)] pub enum RenderOutputType { #[default] Svg, Vello, } /// Static state used whilst rendering #[derive(Default, Clone, CacheHash)] pub struct RenderParams { pub render_mode: RenderMode, pub footprint: Footprint, #[cache_hash(skip)] pub scale: f64, pub render_output_type: RenderOutputType, pub thumbnail: bool, /// Are we exporting pub for_export: bool, /// Are we generating a mask in this render pass? Used to see if fill should be multiplied with alpha. pub for_mask: bool, /// Are we generating a mask for alignment? Used to prevent unnecessary transforms in masks pub alignment_parent_transform: Option, pub aligned_strokes: bool, pub override_paint_order: bool, /// Are we rendering for a pattern content pub inside_pattern: bool, pub artboard_background: Option, /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. pub viewport_zoom: f64, } impl RenderParams { pub fn for_clipper(&self) -> Self { Self { for_mask: true, ..*self } } pub fn for_alignment(&self, transform: DAffine2) -> Self { Self { alignment_parent_transform: Some(transform), ..*self } } pub fn for_pattern(&self) -> Self { Self { inside_pattern: true, ..*self } } pub fn to_canvas(&self) -> bool { !self.for_export && !self.thumbnail && !self.for_mask && !self.inside_pattern } } pub fn format_transform_matrix(transform: DAffine2) -> String { if transform == DAffine2::IDENTITY { return String::new(); } transform.to_cols_array().iter().enumerate().fold("matrix(".to_string(), |val, (i, num)| { let num = if num.abs() < 1_000_000_000. { (num * 1_000_000_000.).round() / 1_000_000_000. } else { *num }; let num = if num.is_zero() { "0".to_string() } else { num.to_string() }; let comma = if i == 5 { "" } else { "," }; val + &(num + comma) }) + ")" } /// `(max, min)` factors by which a unit vector is stretched under `transform`'s linear part — the /// principal and minor singular values, equal to the semi-axes of the ellipse a unit circle maps to. /// Equivalent to `(max(sx, sy), min(sx, sy))` for axis-aligned scales, but accounts for shear. fn singular_values(transform: DAffine2) -> (f64, f64) { let m = transform.matrix2; let a = m.x_axis.x; let b = m.x_axis.y; let c = m.y_axis.x; let d = m.y_axis.y; // Eigenvalues of MᵀM via the closed form for a 2×2, both are non-negative let trace = a * a + b * b + c * c + d * d; let det = a * d - b * c; let discriminant = (trace * trace - 4. * det * det).max(0.).sqrt(); let largest_eigenvalue = (trace + discriminant) * 0.5; let smallest_eigenvalue = ((trace - discriminant) * 0.5).max(0.); (largest_eigenvalue.sqrt(), smallest_eigenvalue.sqrt()) } pub fn black_or_white_for_best_contrast(background: Option) -> Color { let Some(bg) = background else { return core_types::consts::LAYER_OUTLINE_STROKE_COLOR }; let alpha = bg.a(); // Un-premultiply, then encode to gamma sRGB to do the composite in display space. let (gamma_r, gamma_g, gamma_b) = if alpha > f32::EPSILON { let [r, g, b, _] = Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb_channels(); (r, g, b) } else { (0., 0., 0.) }; // Composite over black in sRGB space (premultiplied by alpha), then decode to linear for the luminance test. let composited = Color::from_gamma_srgb_channels(gamma_r * alpha, gamma_g * alpha, gamma_b * alpha, 1.); let threshold = (1.05 * 0.05f32).sqrt() - 0.05; if composited.luminance_rec_709() > threshold { Color::BLACK } else { Color::WHITE } } pub fn to_transform(transform: DAffine2) -> usvg::Transform { let cols = transform.to_cols_array(); usvg::Transform::from_row(cols[0] as f32, cols[1] as f32, cols[2] as f32, cols[3] as f32, cols[4] as f32, cols[5] as f32) } fn to_point(p: DVec2) -> kurbo::Point { kurbo::Point::new(p.x, p.y) } fn get_outline_styles(render_params: &RenderParams) -> (kurbo::Stroke, peniko::Color) { use core_types::consts::LAYER_OUTLINE_STROKE_WEIGHT; let outline_stroke = kurbo::Stroke { width: LAYER_OUTLINE_STROKE_WEIGHT / if render_params.viewport_zoom > 0. { render_params.viewport_zoom } else { 1. }, miter_limit: 4., join: Join::Miter, start_cap: Cap::Butt, end_cap: Cap::Butt, dash_pattern: Default::default(), dash_offset: 0., }; let outline_color = black_or_white_for_best_contrast(render_params.artboard_background); let outline_color_peniko = SRGBA8::from(outline_color).to_peniko_color(); (outline_stroke, outline_color_peniko) } fn draw_raster_outline(scene: &mut Scene, outline_transform: &DAffine2, render_params: &RenderParams) { use graphic_types::vector_types::vector::PointId; let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); let mut outline_path = Subpath::::new_rectangle(DVec2::ZERO, DVec2::ONE).to_bezpath(); outline_path.apply_affine(Affine::new(outline_transform.to_cols_array())); scene.stroke(&outline_stroke, Affine::IDENTITY, outline_color_peniko, None, &outline_path); } /// Emits an SVG `` element with the resolved fill attribute corresponding to the given fill_graphic. #[allow(clippy::too_many_arguments)] fn emit_svg_fill_path( render: &mut SvgRender, d: String, fill_graphic_list: Option<&List>, item_transform: DAffine2, element_transform: DAffine2, applied_stroke_transform: DAffine2, bounds_matrix: DAffine2, render_params: &RenderParams, ) { render.leaf_tag("path", |attributes| { attributes.push("d", d); let matrix = format_transform_matrix(element_transform); if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix); } let defs = &mut attributes.0.svg_defs; let fill_attribute = fill_graphic_list .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()); attributes.push_val(fill_attribute); }); } /// Whether the affine transform inverts to a finite matrix (a zero, subnormal, or NaN determinant does not). pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { transform.matrix2.determinant().recip().is_finite() } /// Maps a gradient's `transform` into the frame handed to the renderer: radial keeps the full matrix (so a /// non-uniform transform makes an ellipse), while linear is reduced to the equivalent non-sheared gradient line (the /// axis projected onto the band normal) so the iso-color bands keep following a sheared transform, which Vello can /// represent since it stores only two endpoints. pub(crate) fn gradient_placement(transform: DAffine2, gradient_form: GradientForm) -> DAffine2 { match gradient_form { GradientForm::Radial => transform, GradientForm::Linear => { let axis = transform.matrix2.x_axis; let band_normal = transform.matrix2.y_axis.perp(); let line = if band_normal.length_squared() > 0. { axis.project_onto(band_normal) } else { axis }; DAffine2 { matrix2: DMat2::from_cols(line, line.perp()), translation: transform.translation, } } } } /// Texel count of the baked gradient ramp Vello samples stops through (`N_SAMPLES`/`GRADIENT_WIDTH` in vello_encoding). const VELLO_GRADIENT_RAMP_TEXELS: f64 = 512.; /// Renderable gradient samples of `(position, color, original midpoint)`, as produced by [`Gradient::interpolated_samples`]. type GradientSamples = Vec<(f64, Color, Option)>; /// Where a renderer needs the transparent guard stops that emulate the `Clear` spread, which neither SVG nor Vello supports natively. #[derive(Copy, Clone, PartialEq)] pub(crate) enum ClearGuardPlacement { /// Guards share the range ends' exact offsets, resolved against the visible colors by stop order alone. SvgStopOrder, /// Guards own the outermost ramp texel at each cleared end, since Vello's pad extension samples those texels for /// everything beyond the ends and its ramp bake would tie-break a shared-offset guard away. The visible range /// compresses inward by one texel per cleared end, costing about 0.4% of the ramp's color resolution. VelloRampTexels, } /// The gradient's renderable samples plus the gradient-space span `(start, end)` the renderer's 0 to 1 offset range must cover, normally the unit interval with the samples unchanged. /// /// The `Clear` spread brackets the samples with transparent guard stops placed per `guards`: the pad extension then /// paints transparency outward while hard stops cut the paint off exactly at the unit range's boundaries. A radial /// gradient's span still starts at zero, since its sampling distance never goes below the center. pub(crate) fn spread_adjusted_samples(gradient: &Gradient, settings: GradientSettings, gradient_form: GradientForm, guards: ClearGuardPlacement) -> (GradientSamples, (f64, f64)) { let samples = gradient.interpolated_samples(settings); if settings.spread != GradientSpread::Clear { return (samples, (0., 1.)); } // The remapped offsets where the visible range's ends land, with the guards owning whatever lies outside them let texel = 1. / (VELLO_GRADIENT_RAMP_TEXELS - 1.); let (start_offset, end_offset) = match (guards, gradient_form) { (ClearGuardPlacement::SvgStopOrder, _) => (0., 1.), (ClearGuardPlacement::VelloRampTexels, GradientForm::Linear) => (texel, 1. - texel), (ClearGuardPlacement::VelloRampTexels, GradientForm::Radial) => (0., 1. - texel), }; let remap = |position: f64| (1. - position) * start_offset + position * end_offset; // The geometric span grows to compensate for the compression, keeping the visible range at the unit interval let scale = 1. / (end_offset - start_offset); let span = (-start_offset * scale, (1. - start_offset) * scale); // A stopless gradient paints solid black, matching `Gradient::evaluate` let first_color = samples.first().map_or(Color::BLACK, |&(_, color, _)| color); let last_color = samples.last().map_or(Color::BLACK, |&(_, color, _)| color); let needs_start_anchor = samples.first().is_none_or(|&(position, ..)| position > 0.); let needs_end_anchor = samples.last().is_none_or(|&(position, ..)| position < 1.); let mut adjusted = Vec::with_capacity(samples.len() + 4); // Lead with the transparent guard (linear only, a radial's center is already the sampling minimum), then anchor the visible range's start color if gradient_form == GradientForm::Linear { adjusted.push((0., Color::TRANSPARENT, None)); } if needs_start_anchor { adjusted.push((remap(0.), first_color, None)); } adjusted.extend(samples.into_iter().map(|(position, color, midpoint)| (remap(position), color, midpoint))); // Anchor the visible range's end color, then cut to the trailing transparent guard if needs_end_anchor { adjusted.push((remap(1.), last_color, None)); } adjusted.push((1., Color::TRANSPARENT, None)); (adjusted, span) } /// Converts a gradient's renderer samples to peniko color stops, duplicating an off-zero first stop at position 0 since Vello ignores the first stop's position and always treats it as 0. fn peniko_color_stops(samples: &[(f64, Color, Option)]) -> peniko::ColorStops { let mut peniko_stops = peniko::ColorStops::new(); for &(position, color, _) in samples { let color = peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()); if peniko_stops.is_empty() && position > 0. { peniko_stops.push(peniko::ColorStop { offset: 0., color }); } peniko_stops.push(peniko::ColorStop { offset: position as f32, color }); } // A gradient with no stops paints as solid black, matching `Gradient::evaluate` if peniko_stops.is_empty() { peniko_stops.push(peniko::ColorStop { offset: 0., color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(Color::BLACK).to_peniko_color()), }); } peniko_stops } /// The peniko extend mode for a spread; `Clear` rides pad, with the transparent guard stops from `spread_adjusted_samples` doing the clearing. fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend { match gradient_spread { GradientSpread::Pad | GradientSpread::Clear => peniko::Extend::Pad, GradientSpread::Reflect => peniko::Extend::Reflect, GradientSpread::Repeat => peniko::Extend::Repeat, } } fn create_peniko_gradient_brush(gradient_list: &List, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; let gradient_form: GradientForm = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_FORM, 0); let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); let settings = GradientSettings::from_list_row_attributes(gradient_list, 0); let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); let peniko_stops = peniko_color_stops(&samples); // The unit gradient is placed by the desheared frame so a non-uniform transform produces the intended ellipse let (start, end, gradient_to_device) = (DVec2::X * span.0, DVec2::X * span.1, gradient_placement(multiplied_transform * gradient_transform, gradient_form)); let brush = peniko::Brush::Gradient(peniko::Gradient { kind: match gradient_form { GradientForm::Linear => peniko::LinearGradientPosition { start: to_point(start), end: to_point(end), } .into(), GradientForm::Radial => peniko::RadialGradientPosition { start_center: to_point(start), start_radius: 0., end_center: to_point(start), end_radius: start.distance(end) as f32, } .into(), }, extend: peniko_extend(settings.spread), stops: peniko_stops, // Straight alpha, keeping parity with the SVG renderer's stop interpolation interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, ..Default::default() }); Some((brush, gradient_to_device)) } // TODO: Click targets can be removed from the render output, since the vector data is available in the vector modify data from Monitor nodes. // This will require that the transform for child layers into that layer space be calculated, or it could be returned from the RenderOutput instead of click targets. #[derive(Debug, Default, Clone, PartialEq, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct RenderMetadata { pub upstream_footprints: HashMap, pub local_transforms: HashMap, pub first_element_source_id: HashMap>, pub click_targets: HashMap>>, /// Source-geometry outlines for hover/selection overlays, separate from `click_targets` so /// nodes with an `editor:click_target` override still outline the precise geometry. pub outlines: HashMap>>, /// Per-layer text frame from row 0's `editor:text_frame` attribute. /// The Text tool composes this with `transform_to_viewport(layer)` to position its drag cage. pub text_frames: HashMap, pub clip_targets: HashSet, pub vector_data: HashMap>, /// Per-layer `ATTR_FILL` row attribute, exposed so message handlers can read it. #[cfg_attr(feature = "serde", serde(skip))] pub fill_attributes: HashMap>>, /// Per-layer `ATTR_STROKE` row attribute, exposed so message handlers can read it. #[cfg_attr(feature = "serde", serde(skip))] pub stroke_attributes: HashMap>>, pub backgrounds: Vec, } impl RenderMetadata { pub fn apply_transform(&mut self, transform: DAffine2) { for value in self.upstream_footprints.values_mut() { value.transform = transform * value.transform; } } /// Merge another RenderMetadata into this one. /// Values from `other` take precedence for duplicate keys. pub fn merge(&mut self, other: &RenderMetadata) { // Destructure Self to get errors when new fields are added to the struct let RenderMetadata { upstream_footprints, local_transforms, first_element_source_id, click_targets, outlines, text_frames, clip_targets, vector_data, fill_attributes, stroke_attributes, backgrounds, } = self; upstream_footprints.extend(other.upstream_footprints.iter()); local_transforms.extend(other.local_transforms.iter()); first_element_source_id.extend(other.first_element_source_id.iter()); click_targets.extend(other.click_targets.iter().map(|(k, v)| (*k, v.clone()))); outlines.extend(other.outlines.iter().map(|(k, v)| (*k, v.clone()))); text_frames.extend(other.text_frames.iter()); clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); fill_attributes.extend(other.fill_attributes.iter().map(|(id, data)| (*id, data.clone()))); stroke_attributes.extend(other.stroke_attributes.iter().map(|(id, data)| (*id, data.clone()))); // TODO: Find a better non O(n^2) way to merge backgrounds for background in &other.backgrounds { if !backgrounds.contains(background) { backgrounds.push(background.clone()); } } } } #[derive(Debug, Default, Clone, PartialEq, DynAny, serde::Serialize, serde::Deserialize)] pub struct Background { pub location: DVec2, pub dimensions: DVec2, } // TODO: Rename to "Graphical" pub trait Render: BoundingBox + RenderComplexity { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams); fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, _render_params: &RenderParams); /// The upstream click targets for each layer are collected during the render so that they do not have to be calculated for each click detection. fn add_upstream_click_targets(&self, _click_targets: &mut Vec) {} /// Like `add_upstream_click_targets` but for visual outlines. `List` overrides this to ignore `editor:click_target` so outlines reflect the actual geometry. fn add_upstream_outline_targets(&self, outlines: &mut Vec) { self.add_upstream_click_targets(outlines); } // TODO: Store all click targets in a vec which contains the AABB, click target, and path // fn add_click_targets(&self, click_targets: &mut Vec<([DVec2; 2], ClickTarget, Vec)>, current_path: Option) {} /// Recursively iterate over data in the render (including nested layer stacks upstream of a vector node, in the case of a boolean operation) to collect the footprints, click targets, and vector modify. fn collect_metadata(&self, _metadata: &mut RenderMetadata, _footprint: Footprint, _element_id: Option) {} fn contains_artboard(&self) -> bool { false } fn new_ids_from_hash(&mut self, _reference: Option) {} } impl Render for Graphic { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { match self { Graphic::None => (), Graphic::Graphic(list) => list.render_svg(render, render_params), Graphic::Vector(list) => list.render_svg(render, render_params), Graphic::RasterCPU(list) => list.render_svg(render, render_params), Graphic::RasterGPU(_) => (), Graphic::Color(list) => list.render_svg(render, render_params), Graphic::Gradient(list) => list.render_svg(render, render_params), Graphic::Text(list) => list.render_svg(render, render_params), } } fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { match self { Graphic::None => (), Graphic::Graphic(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Vector(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::RasterCPU(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::RasterGPU(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params), } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { if let Some(element_id) = element_id { match self { Graphic::None => {} Graphic::Graphic(_) => { metadata.upstream_footprints.insert(element_id, footprint); } Graphic::Vector(list) => { metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than the first item if !list.is_empty() { let layer_path: List = list.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, 0).0; let layer = layer_path.iter_element_values().next_back().copied(); let transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); metadata.first_element_source_id.insert(element_id, layer); metadata.local_transforms.insert(element_id, transform); } } Graphic::RasterCPU(list) => { metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than the first item if !list.is_empty() { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } Graphic::RasterGPU(list) => { metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than the first item if !list.is_empty() { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } Graphic::Color(list) => { metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than the first item if !list.is_empty() { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } Graphic::Gradient(list) => { metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than the first item if !list.is_empty() { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } Graphic::Text(list) => { metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than the first item if !list.is_empty() { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); } } } } match self { Graphic::None => (), Graphic::Graphic(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Vector(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::RasterCPU(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id), } } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { match self { Graphic::None => (), Graphic::Graphic(list) => list.add_upstream_click_targets(click_targets), Graphic::Vector(list) => list.add_upstream_click_targets(click_targets), Graphic::RasterCPU(list) => list.add_upstream_click_targets(click_targets), Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets), Graphic::Color(list) => list.add_upstream_click_targets(click_targets), Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets), Graphic::Text(list) => list.add_upstream_click_targets(click_targets), } } fn add_upstream_outline_targets(&self, outlines: &mut Vec) { match self { Graphic::None => (), Graphic::Graphic(list) => list.add_upstream_outline_targets(outlines), Graphic::Vector(list) => list.add_upstream_outline_targets(outlines), Graphic::RasterCPU(list) => list.add_upstream_outline_targets(outlines), Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines), Graphic::Color(list) => list.add_upstream_outline_targets(outlines), Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines), Graphic::Text(list) => list.add_upstream_outline_targets(outlines), } } fn contains_artboard(&self) -> bool { match self { Graphic::None => false, Graphic::Graphic(list) => list.contains_artboard(), Graphic::Vector(list) => list.contains_artboard(), Graphic::RasterCPU(list) => list.contains_artboard(), Graphic::RasterGPU(list) => list.contains_artboard(), Graphic::Color(list) => list.contains_artboard(), Graphic::Gradient(list) => list.contains_artboard(), Graphic::Text(list) => list.contains_artboard(), } } fn new_ids_from_hash(&mut self, reference: Option) { match self { Graphic::None => (), Graphic::Graphic(list) => list.new_ids_from_hash(reference), Graphic::Vector(list) => list.new_ids_from_hash(reference), Graphic::RasterCPU(_) => (), Graphic::RasterGPU(_) => (), Graphic::Color(_) => (), Graphic::Gradient(_) => (), Graphic::Text(_) => (), } } } /// 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.attribute_cloned_or_default(ATTR_LOCATION, index); let dimensions: DVec2 = list.attribute_cloned_or_default(ATTR_DIMENSIONS, index); let background: Color = list.attribute_cloned_or_default(ATTR_BACKGROUND, index); let clip: bool = list.attribute_cloned_or_default(ATTR_CLIP, index); (location, dimensions, background, clip) } 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); }, ); } } 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(); } } } 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: List = self.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, index).0; let element_id = layer_path.iter_element_values().next_back().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_upstream_click_targets(&self, click_targets: &mut Vec) { for index in 0..self.len() { let dimensions: DVec2 = self.attribute_cloned_or_default(ATTR_DIMENSIONS, index); let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions); click_targets.push(ClickTarget::new_with_subpath(subpath_rectangle, 0.)); } } fn contains_artboard(&self) -> bool { !self.is_empty() } } impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { let mut mask_state = None; for index in 0..self.len() { let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let element = self.element(index).unwrap(); let matrix = format_transform_matrix(transform); let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled(); let mut masked_by = None; 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 }; let mut svg = SvgRender::new(); element.render_svg(&mut svg, &render_params.for_clipper()); // The def is resolved in this list's space, so the masker's own transform has to be baked into it let masker = match matrix.is_empty() { true => svg.svg.to_svg_string(), false => format!(r##"{}"##, svg.svg.to_svg_string()), }; render.svg_defs.push_str(&svg.svg_defs); mask_type.write_to_defs(&mut render.svg_defs, uuid, masker); mask_state = Some((uuid, mask_type)); } else if let Some((uuid, mask_type)) = mask_state { if !next_clips { mask_state = None; } masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})"))); } let render_item = |render: &mut SvgRender| { render.parent_tag( "g", |attributes| { if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix.clone()); } let 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()); } }, |render| element.render_svg(render, render_params), ); }; // The mask rides an untransformed wrapper so it resolves in this list's space rather than the item's own match masked_by { Some((attribute, selector)) => render.parent_tag("g", |attributes| attributes.push(attribute, selector), render_item), None => render_item(render), } } } fn render_to_vello(&self, 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.attribute_cloned_or_default(ATTR_TRANSFORM, index); let transform = transform * item_transform; let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let element = self.element(index).unwrap(); 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 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 < 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)); 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); } if layer { scene.pop_layer(); } } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { for index in 0..self.len() { let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let layer_path: List = self.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, index).0; let layer = layer_path.iter_element_values().next_back().copied(); let element = self.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); } } 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.attribute_cloned_or_default(ATTR_TRANSFORM, 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.attribute_cloned_or_default(ATTR_TRANSFORM, 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); } } fn add_upstream_outline_targets(&self, outlines: &mut Vec) { for index in 0..self.len() { let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 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); } } fn contains_artboard(&self) -> bool { self.iter_element_values().any(|element| element.contains_artboard()) } fn new_ids_from_hash(&mut self, _reference: Option) { let (elements, layers) = self.element_and_attribute_slices_mut::(ATTR_EDITOR_LAYER_PATH); for (element, layer) in elements.iter_mut().zip(layers.iter()) { element.new_ids_from_hash(layer.0.iter_element_values().next_back().copied()); } } } /// Emits one item of a `List` as SVG, with no wrapping group of its own. fn render_vector_item_svg(list: &List, index: usize, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) { let item_transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = list.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform)); let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform); let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse()); let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); let layer_bounds = vector.bounding_box().unwrap_or_default(); let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default(); let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds); 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(); for mut bezpath in vector.stroke_bezpath_iter() { bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); path.push_str(bezpath.to_svg().as_str()); } let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) { MaskType::Clip } else { MaskType::Mask }; let fill_graphic_list = graphic_list_at(list, index, ATTR_FILL); let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); let stroke_graphic_list = graphic_list_at(list, index, ATTR_STROKE); let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0)); let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); let can_draw_aligned_stroke = path_is_closed && vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()); let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip); let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; let use_face_fill = vector.use_face_fill(); if needs_separate_alignment_fill && !wants_stroke_below { emit_svg_fill_path( render, path.clone(), fill_graphic_list.as_deref(), item_transform, element_transform, applied_stroke_transform, bounds_matrix, render_params, ); } let push_id = needs_separate_alignment_fill.then_some({ let id = format!("alignment-{}", generate_uuid()); let mut cloned_vector = vector.clone(); cloned_vector.stroke = None; // The mask must draw at full alpha so the SVG ``/`` 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(), fill_graphic_list.as_deref(), item_transform, element_transform, applied_stroke_transform, bounds_matrix, render_params, ); } } impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { let mut clip_mask_state: Option<(u64, MaskType)> = None; for index in 0..self.len() { let Some(vector) = self.element(index) else { continue }; // A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::(ATTR_CLIPPING_MASK, index + 1); let mut masked_by = None; if next_clips && clip_mask_state.is_none() { let masker = Graphic::Vector(List::new_from_item(Item::from_parts(vector.clone(), self.clone_item_attributes(index)))); let mask_type = if masker.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask }; let uuid = generate_uuid(); let mut masker_svg = SvgRender::new(); masker.render_svg(&mut masker_svg, &render_params.for_clipper()); render.svg_defs.push_str(&masker_svg.svg_defs); mask_type.write_to_defs(&mut render.svg_defs, uuid, masker_svg.svg.to_svg_string()); clip_mask_state = Some((uuid, mask_type)); } else if let Some((uuid, mask_type)) = clip_mask_state { if !next_clips { clip_mask_state = None; } masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})"))); } // Item geometry is baked into the path data instead of a group transform, so mask coordinates line up match masked_by { Some((attribute, selector)) => render.parent_tag( "g", |attributes| attributes.push(attribute, selector), |render| render_vector_item_svg(self, index, vector, render, render_params), ), None => render_vector_item_svg(self, index, vector, render, render_params), } } } fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { let mut clip_masker: Option> = None; 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.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); 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()); // A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::(ATTR_CLIPPING_MASK, index + 1); let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; let needs_blend_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); // Shared by the blend and clipping layers below, so it is only worth deriving when one of them is pushed let layer_geometry = (needs_blend_layer || clip_masker.is_some()).then(|| { // `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`. // Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform. let (_, smallest_scale) = singular_values(applied_stroke_transform); let stroke_inflation = stroke.map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke)); let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. }; let bounds = Quad::from_box(layer_bounds).inflate(inflate_amount).bounding_box(); ( kurbo::Affine::new(multiplied_transform.to_cols_array()), kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), ) }); if needs_blend_layer && let Some((layer_affine, layer_rect)) = layer_geometry { layer = true; scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), opacity, layer_affine, &layer_rect); } // Pushed inside the blend layer so the mask cuts this item's own paint rather than the composited result let mut clip_layers = false; if next_clips && clip_masker.is_none() { clip_masker = Some(List::new_from_item(Item::from_parts(element.clone(), self.clone_item_attributes(index)))); } else if let Some(masker) = clip_masker.as_ref() { if let Some((layer_affine, layer_rect)) = layer_geometry { scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect); masker.render_to_vello(scene, parent_transform, context, &render_params.for_clipper()); scene.push_layer( peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), 1., layer_affine, &layer_rect, ); clip_layers = true; } if !next_clips { clip_masker = None; } } let use_layer = can_draw_aligned_stroke; 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::None => continue, 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(_) => { 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::None => continue, 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(_) => { 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 clip_layers { scene.pop_layer(); scene.pop_layer(); } // If we pushed a layer for opacity or a blend mode, we need to pop it if layer { scene.pop_layer(); } } } 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. the "Text to Vector Glyphs" node) produce hit areas for every glyph. // Targets are baked relative to the first item carrying each element_id, since that is the transform recorded as its `local_transforms` entry. let mut reference_transforms: HashMap = HashMap::new(); 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.attribute_cloned_or_default(ATTR_TRANSFORM, index); let layer_path: List = self.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, index).0; let layer = layer_path.iter_element_values().next_back().copied(); if let Some(element_id) = caller_element_id.or(layer) { let reference_transform = *reference_transforms.entry(element_id).or_insert(transform); let reference_inverse = if transform_is_invertible(reference_transform) { reference_transform.inverse() } else { DAffine2::IDENTITY }; // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes) let click_target_vector = self.attribute::(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source); let item_relative_transform = reference_inverse * transform; let mut click_targets_unwrapped = Vec::new(); extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform); 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.attribute::(ATTR_EDITOR_TEXT_FRAME, 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, // Combine Paths, Morph, or any other destructive merge), recurse into that snapshot so the editor can // surface the original child layers' click targets. let upstream_nested_layers = self.attribute_cloned_or_default::>(ATTR_EDITOR_MERGED_LAYERS, index); if !upstream_nested_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); } // Recovering element_id from `editor:layer_path` means `Graphic::collect_metadata` skipped this transform metadata. // It lands after the snapshot recursion above so each element keeps the pair its targets were baked against. if caller_element_id.is_none() { for (element_id, reference_transform) in reference_transforms { metadata.upstream_footprints.insert(element_id, footprint); metadata.local_transforms.insert(element_id, reference_transform); } } } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { for index in 0..self.len() { let Some(source) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) let vector = self.attribute::(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source); extend_targets_from_vector(click_targets, self, index, vector, transform); } } 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.attribute_cloned_or_default(ATTR_TRANSFORM, index); extend_targets_from_vector(outlines, self, index, source, transform); } } fn new_ids_from_hash(&mut self, reference: Option) { for vector in self.iter_element_values_mut() { vector.vector_new_ids_from_hash(reference.map(|id| id.0).unwrap_or_default()); } } } /// 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); let mut subpaths: Vec> = geometry.stroke_bezier_paths().collect(); let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed()); // Inside/Outside-aligned strokes reach `weight` from the centerline rather than `weight / 2` per side, // so they need double the click inflation. Alignment is only honored by the renderer for fully-closed paths. let stroke_width = geometry.stroke.as_ref().map_or(0., |stroke| { if stroke.align.is_not_centered() && all_subpaths_closed { stroke.weight * 2. } else { stroke.weight } }); if filled { for subpath in &mut subpaths { subpath.set_closed(true); } } if !subpaths.is_empty() { let mut click_target = ClickTarget::new_with_compound_path(subpaths, stroke_width); click_target.apply_transform(transform); targets.push(click_target); } for click_target in extend_free_point_targets(geometry, transform) { targets.push(click_target); } } fn extend_free_point_targets(vector: &Vector, transform: DAffine2) -> impl Iterator + '_ { // Mark every point index touched by a segment endpoint in one `O(points + segments)` pass, avoiding a per-point `any_connected` scan let mut connected = vec![false; vector.point_domain.len()]; for &point_index in vector.segment_domain.start_point().iter().chain(vector.segment_domain.end_point()) { connected[point_index] = true; } vector.point_domain.ids().iter().enumerate().filter_map(move |(point_index, &point_id)| { if connected[point_index] { return None; } let anchor = vector.point_domain.position_from_id(point_id).unwrap_or_default(); let mut click_target = ClickTarget::new_with_free_point(FreePoint::new(point_id, anchor)); click_target.apply_transform(transform); Some(click_target) }) } 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 }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); 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); 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); 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_attr != BlendMode::default() { attributes.push("style", blend_mode_attr.render()); } }); } } } 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 blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); 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.attribute_cloned_or_default(ATTR_TRANSFORM, 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_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.attribute_cloned_or_default(ATTR_TRANSFORM, 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). let upstream_nested_layers = self.attribute_cloned_or_default::>(ATTR_EDITOR_MERGED_LAYERS, 0); if !upstream_nested_layers.is_empty() { upstream_nested_layers.collect_metadata(metadata, footprint, None); } } } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { for index in 0..self.len() { // The unit square is the raster's own space, so its placement only exists in the item transform let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); subpath.apply_transform(transform); click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); } } } static LAZY_ARC_VEC_ZERO_U8: LazyLock>> = LazyLock::new(|| Arc::new(Vec::new())); 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.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let clip_attr: bool = self.attribute_cloned_or_default(ATTR_CLIPPING_MASK, 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.attribute_cloned_or_default(ATTR_TRANSFORM, 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() } } } 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.attribute_cloned_or_default(ATTR_TRANSFORM, 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). let upstream_nested_layers = self.attribute_cloned_or_default::>(ATTR_EDITOR_MERGED_LAYERS, 0); if !upstream_nested_layers.is_empty() { upstream_nested_layers.collect_metadata(metadata, footprint, None); } } } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { for index in 0..self.len() { let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); subpath.apply_transform(transform); click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); } } } // Since colors and gradients are technically infinitely big, we have to implement // workarounds for rendering them correctly in a way which still allows us // to cache the intermediate render data (SVG string/Vello scene). // For SVG, this is is achived by creating a truly giant rectangle. // For Vello, we create a layer with a placeholder transform which we // later replace with the current viewport transform before each render. impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for (index, color) in self.iter_element_values().enumerate() { let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); render.leaf_tag("polyline", |attributes| { // 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}")); attributes.push("fill", format!("#{}", SRGBA8::from(*color).to_rgb_hex())); if color.a() < 1. { attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).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 != BlendMode::default() { attributes.push("style", blend_mode.render()); } }); } } fn render_to_vello(&self, scene: &mut Scene, _parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { use vello::peniko; for (index, color) in self.iter_element_values().enumerate() { let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); 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 vello_color = SRGBA8::from(*color).to_peniko_color(); 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); scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect); layer = true; } scene.fill(peniko::Fill::NonZero, kurbo::Affine::scale(f64::INFINITY), vello_color, None, &rect); if layer { scene.pop_layer(); } } } } /// A gradient's control geometry in its local space: the unit circle a radial gradient's transform carries to its drawn ellipse, or the (0,0) to (1,0) gradient line for a linear one. fn gradient_control_outline(gradient_form: GradientForm) -> Subpath { match gradient_form { GradientForm::Linear => Subpath::new_line(DVec2::ZERO, DVec2::X), GradientForm::Radial => Subpath::new_ellipse(DVec2::splat(-1.), DVec2::splat(1.)), } } /// Whether the control geometry's interior is a draggable click area: a radial's main ellipse acts as the layer's handle regardless of spread, while a linear's control line has no interior. fn gradient_control_interior_is_clickable(gradient_form: GradientForm) -> bool { gradient_form == GradientForm::Radial } 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 }; for index in 0..self.len() { let Some(gradient) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); let settings = GradientSettings::from_list_row_attributes(self, 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 (samples, _) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); let mut stop_string = String::new(); for (position, color, original_midpoint) in 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 gradient_spread_attribute = if matches!(settings.spread, GradientSpread::Pad | GradientSpread::Clear) { String::new() } else { format!(r#" spreadMethod="{}""#, settings.spread.svg_name()) }; // The unit gradient line is the +X unit vector in local space, before the item's transform is applied match gradient_form { GradientForm::Linear => { let _ = write!( &mut attributes.0.svg_defs, r#"{stop_string}"# ); } GradientForm::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_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, gradient), gradient_form) in self.iter_element_values().enumerate().zip(self.iter_attribute_values_or_default::(ATTR_GRADIENT_FORM)) { let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); 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 settings = GradientSettings::from_list_row_attributes(self, index); let (samples, span) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); let stops = peniko_color_stops(&samples); let extend = peniko_extend(settings.spread); // 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_form { GradientForm::Linear => peniko::LinearGradientPosition { start: to_point(DVec2::X * span.0), end: to_point(DVec2::X * span.1), } .into(), GradientForm::Radial => peniko::RadialGradientPosition { start_center: to_point(DVec2::ZERO), start_radius: 0., end_center: to_point(DVec2::ZERO), end_radius: span.1 as f32, } .into(), }; let fill = peniko::Brush::Gradient(peniko::Gradient { kind, stops, extend, interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, ..Default::default() }); let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_form).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(); } } } fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option) { let Some(element_id) = element_id else { return }; if self.is_empty() { return; } // Targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]` let item_zero_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); let item_zero_inverse = if transform_is_invertible(item_zero_transform) { item_zero_transform.inverse() } else { DAffine2::IDENTITY }; let mut outline_targets = Vec::new(); let mut click_targets = Vec::new(); for index in 0..self.len() { let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); target.apply_transform(item_zero_inverse * item_transform); let target = Arc::new(target); if gradient_control_interior_is_clickable(gradient_form) { click_targets.push(target.clone()); } outline_targets.push(target); } metadata.outlines.insert(element_id, outline_targets); if !click_targets.is_empty() { metadata.click_targets.insert(element_id, click_targets); } } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { for index in 0..self.len() { let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); if !gradient_control_interior_is_clickable(gradient_form) { continue; } let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); target.apply_transform(transform); click_targets.push(target); } } fn add_upstream_outline_targets(&self, outlines: &mut Vec) { for index in 0..self.len() { let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); target.apply_transform(transform); outlines.push(target); } } } /// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`). struct GlyphOutlinePen<'a> { path: &'a mut BezPath, ox: f64, oy: f64, tilt_tan: f64, } impl GlyphOutlinePen<'_> { #[inline] fn px(&self, x: f32, y: f32) -> f64 { self.ox + x as f64 + (y as f64 * self.tilt_tan) } #[inline] fn py(&self, y: f32) -> f64 { self.oy - y as f64 } } impl OutlinePen for GlyphOutlinePen<'_> { fn move_to(&mut self, x: f32, y: f32) { self.path.move_to((self.px(x, y), self.py(y))); } fn line_to(&mut self, x: f32, y: f32) { self.path.line_to((self.px(x, y), self.py(y))); } fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { self.path.quad_to((self.px(cx, cy), self.py(cy)), (self.px(x, y), self.py(y))); } fn curve_to(&mut self, cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32) { self.path.curve_to((self.px(cx1, cy1), self.py(cy1)), (self.px(cx2, cy2), self.py(cy2)), (self.px(x, y), self.py(y))); } fn close(&mut self) { self.path.close_path(); } } /// Draws each glyph of `glyph_run` into a `BezPath` (with the run's position and faux-italic `tilt_tan` baked in) /// and calls `emit` for each non-empty glyph. Zero-geometry glyphs advance by `space_extra` for justified spacing. fn draw_glyph_run_to_bezpaths(glyph_run: &parley::GlyphRun<'_, ()>, x_offset: f32, space_extra: f32, tilt_tan: f64, mut emit: impl FnMut(&BezPath)) { let mut run_x = glyph_run.offset() + x_offset; let run_y = glyph_run.baseline(); let run = glyph_run.run(); let font = run.font(); let font_size_pts = run.font_size(); let normalized_coords: Vec = run.normalized_coords().iter().map(|c| NormalizedCoord::from_bits(*c)).collect(); let Ok(font_ref) = SkrifaFontRef::from_index(font.data.as_ref(), font.index) else { return }; let outlines = font_ref.outline_glyphs(); let mut bez_path = BezPath::new(); for glyph in glyph_run.glyphs() { let ox = (run_x + glyph.x) as f64; let oy = (run_y - glyph.y) as f64; run_x += glyph.advance; let Some(outline) = outlines.get(GlyphId::from(glyph.id)) else { continue }; let settings = DrawSettings::unhinted(Size::new(font_size_pts), LocationRef::new(&normalized_coords)); bez_path.truncate(0); let path = &mut bez_path; let mut pen = GlyphOutlinePen { path, ox, oy, tilt_tan }; if outline.draw(settings, &mut pen).is_ok() && !bez_path.elements().is_empty() { emit(&bez_path); } else if space_extra != 0. && glyph.advance > 0. { run_x += space_extra; } } } /// Lays out text item `index` of a styled `List` 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)?; let font: Resource = { let f: Resource = list.attribute_cloned_or_default(ATTR_FONT, index); if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } }; let font_size: f64 = list.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); let line_height: f64 = list.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2); let letter_spacing: f64 = list.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.); let max_width: Option = list.attribute_cloned_or(ATTR_MAX_WIDTH, index, None); let max_height: Option = list.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None); let align: text_nodes::TextAlign = list.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index); let transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index); let typesetting = text_nodes::TypesettingConfig { font_size, line_height_ratio: line_height, letter_spacing, letter_tilt: 0., max_width, max_height, align, }; let (width, height) = text_nodes::TextContext::with_thread_local(|ctx| { ctx.layout_text(text, &font, typesetting).map(|layout| { let w = max_width.unwrap_or_else(|| layout.width() as f64); let h = max_height.unwrap_or_else(|| layout.height() as f64); (w, h) }) }) .unwrap_or((font_size, font_size)); Some((DVec2::new(width, height), transform)) } /// Union bounding box of a styled `List`, 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 { 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 }; 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); bounds = Some(match bounds { Some([min, max]) => [min.min(point), max.max(point)], None => [point, point], }); } } match bounds { Some(bounds) => RenderBoundingBox::Rectangle(bounds), None => RenderBoundingBox::None, } } /// 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 { let mut combined: Option<[DVec2; 2]> = None; let mut any_infinite = false; for index in 0..list.len() { let item_transform = transform * list.attribute_cloned_or_default::(ATTR_TRANSFORM, index); let Some(graphic) = list.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), other => other.thumbnail_bounding_box(item_transform, true), }; match bounds { RenderBoundingBox::None => {} RenderBoundingBox::Infinite => any_infinite = true, RenderBoundingBox::Rectangle([min, max]) => { combined = Some(match combined { Some([existing_min, existing_max]) => [existing_min.min(min), existing_max.max(max)], None => [min, max], }) } } } match (combined, any_infinite) { (Some(bounds), _) => RenderBoundingBox::Rectangle(bounds), (None, true) => RenderBoundingBox::Infinite, (None, false) => RenderBoundingBox::None, } } 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; } let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let font: Resource = { let f: Resource = self.attribute_cloned_or_default(ATTR_FONT, index); if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } }; let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); let line_height: f64 = self.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2); let letter_spacing: f64 = self.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.); let max_width: Option = self.attribute_cloned_or(ATTR_MAX_WIDTH, index, None); let max_height: Option = self.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None); let letter_tilt: f64 = self.attribute_cloned_or(ATTR_LETTER_TILT, index, 0.); let align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, 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_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.attribute_cloned_or_default(ATTR_TRANSFORM, index); let font: Resource = { let f: Resource = self.attribute_cloned_or_default(ATTR_FONT, index); if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } }; let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); let line_height: f64 = self.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2); let letter_spacing: f64 = self.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.); let max_width: Option = self.attribute_cloned_or(ATTR_MAX_WIDTH, index, None); let max_height: Option = self.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None); let letter_tilt: f64 = self.attribute_cloned_or(ATTR_LETTER_TILT, index, 0.); let align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); 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(); } }); } } 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.attribute_cloned_or_default(ATTR_TRANSFORM, 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: List = self.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, index).0; let layer = layer_path.iter_element_values().next_back().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); } } 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); } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SvgSegment { Slice(&'static str), String(String), } impl From for SvgSegment { fn from(value: String) -> Self { Self::String(value) } } impl From<&'static str> for SvgSegment { fn from(value: &'static str) -> Self { Self::Slice(value) } } pub trait RenderSvgSegmentList { fn to_svg_string(&self) -> String; } impl RenderSvgSegmentList for Vec { fn to_svg_string(&self) -> String { let mut result = String::new(); for segment in self.iter() { result.push_str(match segment { SvgSegment::Slice(x) => x, SvgSegment::String(x) => x, }); } result } } pub struct SvgRenderAttrs<'a>(&'a mut SvgRender); impl SvgRenderAttrs<'_> { pub fn push_complex(&mut self, name: impl Into, value: impl FnOnce(&mut SvgRender)) { self.0.svg.push(" ".into()); self.0.svg.push(name.into()); self.0.svg.push("=\"".into()); value(self.0); self.0.svg.push("\"".into()); } pub fn push(&mut self, name: impl Into, value: impl Into) { self.push_complex(name, move |renderer| renderer.svg.push(value.into())); } pub fn push_val(&mut self, value: impl Into) { self.0.svg.push(value.into()); } } #[cfg(test)] mod tests { use super::*; use vector_types::gradient::GradientSpace; #[test] fn spread_adjusted_samples_wraps_clear_in_transparent_guards() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); let (samples, span) = spread_adjusted_samples( &gradient, GradientSettings { spread: GradientSpread::Repeat, space: GradientSpace::RgbGamma, ..Default::default() }, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder, ); assert_eq!(span, (0., 1.)); assert_eq!( samples, gradient.interpolated_samples(GradientSettings { space: GradientSpace::RgbGamma, ..Default::default() }) ); // SVG guards share the range ends' exact offsets, ordered so the pad extension resolves to the transparent outer stops let (samples, span) = spread_adjusted_samples( &gradient, GradientSettings { spread: GradientSpread::Clear, space: GradientSpace::RgbGamma, ..Default::default() }, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder, ); assert_eq!(span, (0., 1.)); assert_eq!( samples, vec![(0., Color::TRANSPARENT, None), (0., Color::BLACK, None), (1., Color::WHITE, None), (1., Color::TRANSPARENT, None)] ); // Vello guards own the outermost ramp texels, with the visible range compressed inward to make room let texel = 1. / (VELLO_GRADIENT_RAMP_TEXELS - 1.); let (samples, span) = spread_adjusted_samples( &gradient, GradientSettings { spread: GradientSpread::Clear, space: GradientSpace::RgbGamma, ..Default::default() }, GradientForm::Linear, ClearGuardPlacement::VelloRampTexels, ); assert_eq!( samples, vec![ (0., Color::TRANSPARENT, None), (texel, Color::BLACK, None), (1. - texel, Color::WHITE, None), (1., Color::TRANSPARENT, None) ] ); assert!(span.0 < 0. && span.1 > 1., "the geometry must stretch to compensate for the compressed stops: {span:?}"); // A radial keeps its stops and span anchored at zero, with no guard below the center let (samples, span) = spread_adjusted_samples( &gradient, GradientSettings { spread: GradientSpread::Clear, space: GradientSpace::RgbGamma, ..Default::default() }, GradientForm::Radial, ClearGuardPlacement::VelloRampTexels, ); assert_eq!(span.0, 0.); assert_eq!(samples.first().unwrap(), &(0., Color::BLACK, None)); assert_eq!(samples.last().unwrap(), &(1., Color::TRANSPARENT, None)); } #[test] fn spread_adjusted_samples_keeps_a_stopless_clear_gradient_black_inside_the_range() { let (samples, _) = spread_adjusted_samples( &Gradient::from(Vec::new()), GradientSettings { spread: GradientSpread::Clear, space: GradientSpace::RgbGamma, ..Default::default() }, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder, ); let colors: Vec = samples.iter().map(|&(_, color, _)| color).collect(); assert_eq!(colors, vec![Color::TRANSPARENT, Color::BLACK, Color::BLACK, Color::TRANSPARENT]); } }