use crate::render_ext::{PaintTarget, RenderExt};
use crate::to_peniko::{BlendModeExt, ToPenikoColor};
use core_types::CacheHash;
use core_types::attribute::{
Background as BackgroundAttr, BlendMode as BlendModeAttr, Clip, ClippingMask, Dimensions, EditorLayerPath, EditorTextFrame, FontSize, LetterSpacing, LetterTilt, LineHeight, Location, MaxHeight,
MaxWidth, Opacity, OpacityFill, Transform,
};
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::lane::LaneSource;
use core_types::lane::{LeafLane, Single};
use core_types::list::{Item, List};
use core_types::math::quad::Quad;
use core_types::record::{Group, RunView};
use core_types::render_complexity::RenderComplexity;
use core_types::transform::Footprint;
use core_types::uuid::{NodeId, generate_uuid};
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_TRANSFORM};
use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2};
use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource;
use graphic_types::graphic::{PaintColumns, PaintOverlay, PaintReach, has_paint, is_paint_present, paint_graphics, set_paint_attribute, vector_can_reduce_to_clip_path};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
use graphic_types::vector_types::gradient::{Gradient, GradientForm, GradientSettings};
use graphic_types::vector_types::markers::GradientForm as GradientFormAttr;
use graphic_types::vector_types::subpath::Subpath;
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{ATTR_FILL, 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 text_nodes::markers::{Font, TextAlign};
use vector_types::gradient::GradientSpread;
use vector_types::markers::EditorClickTarget;
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#"".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#"".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());
self.svg.push(name);
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: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
let stops = gradient_list.element(0)?;
let gradient_form: GradientForm = gradient_list.attr::(0);
let gradient_transform: DAffine2 = gradient_list.attr::(0);
let settings = GradientSettings::from_lane_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(vector) => render_vector_svg(&Single(vector), render, render_params),
Graphic::RasterCPU(raster) => render_raster_cpu_svg(&Single(raster), render, render_params),
Graphic::RasterGPU(_) => (),
Graphic::Color(color) => render_color_svg(&Single(color), render, render_params),
Graphic::Gradient(gradient) => render_gradient_svg(&Single(gradient), render, render_params),
Graphic::Text(text) => render_text_svg(&Single(text), render, render_params),
Graphic::Group(group) => render_group_svg(group, PaintReach::NONE, 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(vector) => render_vector_vello(&Single(vector), scene, transform, context, render_params),
Graphic::RasterCPU(raster) => render_raster_cpu_vello(&Single(raster), scene, transform, render_params),
Graphic::RasterGPU(raster) => render_raster_gpu_vello(&Single(raster), scene, transform, context, render_params),
Graphic::Color(color) => render_color_vello(&Single(color), scene, render_params),
Graphic::Gradient(gradient) => render_gradient_vello(&Single(gradient), scene, transform, render_params),
Graphic::Text(text) => render_text_vello(&Single(text), scene, transform, render_params),
Graphic::Group(group) => render_group_vello(group, PaintReach::NONE, scene, transform, context, render_params),
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) {
collect_element_metadata(self, PaintReach::NONE, DAffine2::IDENTITY, None, metadata, footprint, element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec) {
add_element_upstream_click_targets(self, PaintReach::NONE, click_targets)
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec) {
add_element_upstream_outline_targets(self, PaintReach::NONE, outlines)
}
fn contains_artboard(&self) -> bool {
match self {
Graphic::None => false,
Graphic::Graphic(list) => list.contains_artboard(),
_ => false,
}
}
fn new_ids_from_hash(&mut self, reference: Option) {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.new_ids_from_hash(reference),
Graphic::Vector(vector) => vector.vector_new_ids_from_hash(reference.map(|id| id.0).unwrap_or_default()),
_ => (),
}
}
}
fn render_element_svg<'a>(element: &'a Graphic, reach: PaintReach<'a>, render: &mut SvgRender, render_params: &RenderParams) {
match element {
Graphic::Vector(vector) if reach.applies() => render_vector_svg(&PaintOverlay::new(&Single(vector), reach.paint), render, render_params),
Graphic::Graphic(inner) => render_graphic_svg_with(inner, reach.nested(), render, render_params),
Graphic::Group(group) => render_group_svg(group, reach, render, render_params),
_ => element.render_svg(render, render_params),
}
}
fn render_element_vello<'a>(element: &'a Graphic, reach: PaintReach<'a>, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
match element {
Graphic::Vector(vector) if reach.applies() => render_vector_vello(&PaintOverlay::new(&Single(vector), reach.paint), scene, transform, context, render_params),
Graphic::Graphic(inner) => render_graphic_vello_with(inner, reach.nested(), scene, transform, context, render_params),
Graphic::Group(group) => render_group_vello(group, reach, scene, transform, context, render_params),
_ => element.render_to_vello(scene, transform, context, render_params),
}
}
fn element_can_reduce_to_clip_path<'a>(element: &'a Graphic, reach: PaintReach<'a>) -> bool {
match element {
Graphic::Vector(vector) if reach.applies() => vector_can_reduce_to_clip_path(&PaintOverlay::new(&Single(vector), reach.paint)),
Graphic::Group(group) => match RunView::::new(&group.content) {
Some(run) if reach.applies() => vector_can_reduce_to_clip_path(&PaintOverlay::new(&run, reach.paint)),
Some(run) => vector_can_reduce_to_clip_path(&run),
None => false,
},
_ => element.can_reduce_to_clip_path(),
}
}
fn collect_element_metadata<'a>(
element: &'a Graphic,
reach: PaintReach<'a>,
lane_transform: DAffine2,
lane_source: Option,
metadata: &mut RenderMetadata,
footprint: Footprint,
element_id: Option,
) {
if let Some(element_id) = element_id {
metadata.upstream_footprints.insert(element_id, footprint);
match element {
Graphic::Group(group) => collect_group_row_metadata(group, metadata, element_id),
Graphic::Graphic(_) => {}
// A leaf's layer identity and transform ride its containing lane.
Graphic::Vector(_) => {
metadata.first_element_source_id.insert(element_id, lane_source);
metadata.local_transforms.insert(element_id, lane_transform);
}
_ => {
metadata.local_transforms.insert(element_id, lane_transform);
}
}
}
match element {
Graphic::None => {}
Graphic::Graphic(list) => collect_graphic_metadata_with(list, reach.nested(), metadata, footprint, element_id),
Graphic::Vector(vector) if reach.applies() => collect_vector_metadata(&PaintOverlay::new(&Single(vector), reach.paint), metadata, footprint, element_id),
Graphic::Vector(vector) => collect_vector_metadata(&Single(vector), metadata, footprint, element_id),
Graphic::RasterCPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id),
Graphic::RasterGPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id),
Graphic::Color(_) => {}
Graphic::Gradient(gradient) => collect_gradient_metadata(&Single(gradient), metadata, element_id),
Graphic::Text(text) => collect_text_metadata(&Single(text), metadata, footprint, element_id),
Graphic::Group(group) => collect_group_metadata(group, reach, metadata, footprint, element_id),
}
}
/// The id-level metadata the legacy lowering exposed for a group element: a
/// bare typed run serves its lane-0 transform (and source id for vectors) as
/// the layer's local transform, matching the typed list the conversion made.
fn collect_group_row_metadata(group: &Group, metadata: &mut RenderMetadata, element_id: NodeId) {
fn lane_zero_transform(item: &core_types::record::GroupItem) -> Option {
RunView::::new(item).map(|run| run.attr::(0))
}
let item = &group.content;
if group.row.is_some() || item.is_empty() || item.typed_lanes::().is_some() {
return;
}
if let Some(run) = RunView::::new(item) {
let layer_path: &[NodeId] = run.attr::(0);
metadata.first_element_source_id.insert(element_id, layer_path.last().copied());
metadata.local_transforms.insert(element_id, run.attr::(0));
return;
}
let transform = None
.or_else(|| lane_zero_transform::>(item))
.or_else(|| lane_zero_transform::>(item))
.or_else(|| lane_zero_transform::(item))
.or_else(|| lane_zero_transform::(item))
.or_else(|| lane_zero_transform::(item));
if let Some(transform) = transform {
metadata.local_transforms.insert(element_id, transform);
}
}
fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, click_targets: &mut Vec) {
match element {
Graphic::None => (),
Graphic::Graphic(list) => add_graphic_upstream_click_targets_with(list, reach.nested(), click_targets),
Graphic::Vector(vector) if reach.applies() => add_vector_upstream_click_targets(&PaintOverlay::new(&Single(vector), reach.paint), click_targets),
Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), click_targets),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(click_targets),
Graphic::Color(_) => {}
Graphic::Gradient(gradient) => click_targets.extend(gradient_control_targets(&Single(gradient), |transform| transform, true)),
Graphic::Text(text) => add_text_upstream_click_targets(&Single(text), click_targets),
Graphic::Group(group) => add_group_upstream_click_targets(group, reach, click_targets),
}
}
fn add_element_upstream_outline_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, outlines: &mut Vec) {
match element {
Graphic::None => (),
Graphic::Graphic(list) => add_graphic_upstream_outline_targets_with(list, reach.nested(), outlines),
Graphic::Vector(vector) if reach.applies() => add_vector_upstream_outline_targets(&PaintOverlay::new(&Single(vector), reach.paint), outlines),
Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), outlines),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(outlines),
Graphic::Color(_) => {}
Graphic::Gradient(gradient) => outlines.extend(gradient_control_targets(&Single(gradient), |transform| transform, false)),
Graphic::Text(text) => add_text_upstream_click_targets(&Single(text), outlines),
Graphic::Group(group) => add_group_upstream_outline_targets(group, reach, outlines),
}
}
/// The native group render: the run dispatches on its element type into the
/// generic bodies; an unknown element type renders as nothing.
fn render_group_svg<'a>(group: &'a Group, reach: PaintReach<'a>, render: &mut SvgRender, render_params: &RenderParams) {
let item = &group.content;
if let Some(run) = RunView::::new(item) {
render_graphic_svg_with(&run, reach.into_group_graphics(), render, render_params)
} else if let Some(run) = RunView::::new(item) {
match reach.applies() {
true => render_vector_svg(&PaintOverlay::new(&run, reach.paint), render, render_params),
false => render_vector_svg(&run, render, render_params),
}
} else if let Some(run) = RunView::>::new(item) {
render_raster_cpu_svg(&run, render, render_params)
} else if item.typed_lanes::>().is_some() {
} else if let Some(run) = RunView::::new(item) {
render_color_svg(&run, render, render_params)
} else if let Some(run) = RunView::::new(item) {
render_gradient_svg(&run, render, render_params)
} else if let Some(run) = RunView::::new(item) {
render_text_svg(&run, render, render_params)
}
}
fn render_group_vello<'a>(group: &'a Group, reach: PaintReach<'a>, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
let item = &group.content;
if let Some(run) = RunView::::new(item) {
render_graphic_vello_with(&run, reach.into_group_graphics(), scene, transform, context, render_params)
} else if let Some(run) = RunView::::new(item) {
match reach.applies() {
true => render_vector_vello(&PaintOverlay::new(&run, reach.paint), scene, transform, context, render_params),
false => render_vector_vello(&run, scene, transform, context, render_params),
}
} else if let Some(run) = RunView::>::new(item) {
render_raster_cpu_vello(&run, scene, transform, render_params)
} else if let Some(run) = RunView::>::new(item) {
render_raster_gpu_vello(&run, scene, transform, context, render_params)
} else if let Some(run) = RunView::::new(item) {
render_color_vello(&run, scene, render_params)
} else if let Some(run) = RunView::::new(item) {
render_gradient_vello(&run, scene, transform, render_params)
} else if let Some(run) = RunView::::new(item) {
render_text_vello(&run, scene, transform, render_params)
}
}
/// Collects a group as its legacy lowering did: a typed run behaves as the
/// typed variant the conversion produced, so a caller's element id passes
/// through to the typed body unchanged.
fn collect_group_metadata<'a>(group: &'a Group, reach: PaintReach<'a>, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) {
let item = &group.content;
if let Some(run) = RunView::::new(item) {
collect_graphic_metadata_with(&run, reach.into_group_graphics(), metadata, footprint, element_id)
} else if let Some(run) = RunView::::new(item) {
match reach.applies() {
true => collect_vector_metadata(&PaintOverlay::new(&run, reach.paint), metadata, footprint, element_id),
false => collect_vector_metadata(&run, metadata, footprint, element_id),
}
} else if let Some(run) = RunView::>::new(item) {
collect_raster_metadata(&run, metadata, footprint, element_id)
} else if let Some(run) = RunView::>::new(item) {
collect_raster_metadata(&run, metadata, footprint, element_id)
} else if let Some(run) = RunView::::new(item) {
collect_gradient_metadata(&run, metadata, element_id)
} else if item.typed_lanes::().is_some() {
} else if let Some(run) = RunView::::new(item) {
collect_text_metadata(&run, metadata, footprint, element_id)
}
}
fn add_group_upstream_click_targets<'a>(group: &'a Group, reach: PaintReach<'a>, click_targets: &mut Vec) {
let item = &group.content;
if let Some(run) = RunView::::new(item) {
add_graphic_upstream_click_targets_with(&run, reach.into_group_graphics(), click_targets)
} else if let Some(run) = RunView::::new(item) {
match reach.applies() {
true => add_vector_upstream_click_targets(&PaintOverlay::new(&run, reach.paint), click_targets),
false => add_vector_upstream_click_targets(&run, click_targets),
}
} else if item.typed_lanes::>().is_some() || item.typed_lanes::>().is_some() {
add_raster_upstream_click_targets(click_targets)
} else if let Some(run) = RunView::::new(item) {
click_targets.extend(gradient_control_targets(&run, |transform| transform, true))
} else if let Some(run) = RunView::::new(item) {
add_text_upstream_click_targets(&run, click_targets)
}
}
fn add_group_upstream_outline_targets<'a>(group: &'a Group, reach: PaintReach<'a>, outlines: &mut Vec) {
let item = &group.content;
if let Some(run) = RunView::::new(item) {
add_graphic_upstream_outline_targets_with(&run, reach.into_group_graphics(), outlines)
} else if let Some(run) = RunView::::new(item) {
match reach.applies() {
true => add_vector_upstream_outline_targets(&PaintOverlay::new(&run, reach.paint), outlines),
false => add_vector_upstream_outline_targets(&run, outlines),
}
} else if item.typed_lanes::>().is_some() || item.typed_lanes::>().is_some() {
add_raster_upstream_click_targets(outlines)
} else if let Some(run) = RunView::::new(item) {
outlines.extend(gradient_control_targets(&run, |transform| transform, false))
} else if let Some(run) = RunView::::new(item) {
add_text_upstream_click_targets(&run, outlines)
}
}
/// Reads the artboard metadata for the item at `index`.
fn read_artboard_attributes(source: &S, index: usize) -> (DVec2, DVec2, Color, bool) {
let location: DVec2 = source.attr::(index);
let dimensions: DVec2 = source.attr::(index);
let background: Color = source.attr::(index);
let clip: bool = source.attr::(index);
(location, dimensions, background, clip)
}
fn render_artboard_svg<'a, S: LaneSource>>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
for index in 0..source.lane_count() {
let Some(content) = source.element(index).map(Artboard::as_graphic_list) else { continue };
let (location, dimensions, background, clip) = read_artboard_attributes(source, index);
let x = location.x.min(location.x + dimensions.x);
let y = location.y.min(location.y + dimensions.y);
let width = dimensions.x.abs();
let height = dimensions.y.abs();
// Background
render.leaf_tag("rect", |attributes| {
attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
if background.a() < 1. {
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
}
attributes.push("x", x.to_string());
attributes.push("y", y.to_string());
attributes.push("width", width.to_string());
attributes.push("height", height.to_string());
});
// Artwork
render.parent_tag(
// SVG group tag
"g",
// Group tag attributes
|attributes| {
let matrix = format_transform_matrix(DAffine2::from_translation(location));
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
if clip {
let id = format!("artboard-{}", generate_uuid());
let selector = format!("url(#{id})");
write!(
&mut attributes.0.svg_defs,
r##""##,
dimensions.x, dimensions.y,
)
.unwrap();
attributes.push("clip-path", selector);
}
},
// Artwork content
|render| {
let mut render_params = render_params.clone();
render_params.artboard_background = Some(background);
content.render_svg(render, &render_params);
},
);
}
}
fn render_artboard_vello<'a, S: LaneSource>>(source: &S, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
use vello::peniko;
for index in 0..source.lane_count() {
let Some(content) = source.element(index).map(Artboard::as_graphic_list) else { continue };
let (location, dimensions, background, clip) = read_artboard_attributes(source, index);
let [a, b] = [location, location + dimensions];
let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
let artboard_transform = kurbo::Affine::new(transform.to_cols_array());
let color = SRGBA8::from(background).to_peniko_color();
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
scene.pop_layer();
if clip {
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect);
}
// Since the content's transform is right multiplied in when rendering the content, we just need to right multiply by the artboard offset here.
let child_transform = transform * DAffine2::from_translation(location);
let mut render_params = render_params.clone();
render_params.artboard_background = Some(background);
content.render_to_vello(scene, child_transform, context, &render_params);
if clip {
scene.pop_layer();
}
}
}
fn collect_artboard_metadata<'a, S: LaneSource>>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint) {
for index in 0..source.lane_count() {
let Some(content) = source.element(index).map(Artboard::as_graphic_list) else { continue };
let (location, dimensions, _background, clip) = read_artboard_attributes(source, index);
let layer_path: &[NodeId] = source.attr::(index);
let element_id = layer_path.last().copied();
if let Some(element_id) = element_id {
let subpath = Subpath::new_rectangle(DVec2::ZERO, dimensions);
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]);
metadata.upstream_footprints.insert(element_id, footprint);
metadata.local_transforms.insert(element_id, DAffine2::from_translation(location));
if clip {
metadata.clip_targets.insert(element_id);
}
}
metadata.backgrounds.push(Background { location, dimensions });
let mut child_footprint = footprint;
child_footprint.transform *= DAffine2::from_translation(location);
content.collect_metadata(metadata, child_footprint, None);
}
}
fn add_artboard_upstream_click_targets<'a, S: LaneSource>>(source: &S, click_targets: &mut Vec) {
for index in 0..source.lane_count() {
let dimensions: DVec2 = source.attr::(index);
let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions);
click_targets.push(ClickTarget::new_with_subpath(subpath_rectangle, 0.));
}
}
impl Render for List> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_artboard_svg(self, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_artboard_vello(self, scene, transform, context, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, _element_id: Option) {
collect_artboard_metadata(self, metadata, footprint)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec) {
add_artboard_upstream_click_targets(self, click_targets)
}
fn contains_artboard(&self) -> bool {
!self.is_empty()
}
}
fn render_graphic_svg<'e, S: LaneSource>>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
render_graphic_svg_with(source, PaintReach::NONE, render, render_params)
}
fn render_graphic_svg_with<'a, 'e, S: LaneSource>>(source: &'a S, inherited: PaintReach<'a>, render: &mut SvgRender, render_params: &RenderParams) {
let paint_columns = PaintColumns::new(source);
let mut mask_state = None;
for index in 0..source.lane_count() {
let transform: DAffine2 = source.attr::(index);
let blend_mode: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
let element = source.element(index).unwrap();
let reach = inherited.for_lane(&paint_columns, index);
render.parent_tag(
"g",
|attributes| {
let matrix = format_transform_matrix(transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
}
if blend_mode != BlendMode::default() {
attributes.push("style", blend_mode.render());
}
let next_clips = index + 1 < source.lane_count() && source.element(index + 1).unwrap().had_clip_enabled();
if next_clips && mask_state.is_none() {
let uuid = generate_uuid();
let mask_type = if element_can_reduce_to_clip_path(element, reach) { MaskType::Clip } else { MaskType::Mask };
mask_state = Some((uuid, mask_type));
let mut svg = SvgRender::new();
render_element_svg(element, reach, &mut svg, &render_params.for_clipper());
write!(&mut attributes.0.svg_defs, r##"{}"##, svg.svg_defs).unwrap();
mask_type.write_to_defs(&mut attributes.0.svg_defs, uuid, svg.svg.to_svg_string());
} else if let Some((uuid, mask_type)) = mask_state {
if !next_clips {
mask_state = None;
}
let id = format!("mask-{uuid}");
let selector = format!("url(#{id})");
attributes.push(mask_type.to_attribute(), selector);
}
},
|render| {
render_element_svg(element, reach, render, render_params);
},
);
}
}
fn render_graphic_vello<'e, S: LaneSource>>(source: &S, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_graphic_vello_with(source, PaintReach::NONE, scene, transform, context, render_params)
}
fn render_graphic_vello_with<'a, 'e, S: LaneSource>>(
source: &'a S,
inherited: PaintReach<'a>,
scene: &mut Scene,
transform: DAffine2,
context: &mut RenderContext,
render_params: &RenderParams,
) {
let paint_columns = PaintColumns::new(source);
let mut mask_element_and_transform = None;
for index in 0..source.lane_count() {
let item_transform: DAffine2 = source.attr::(index);
let transform = transform * item_transform;
let blend_mode_attr: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
let element = source.element(index).unwrap();
let reach = inherited.for_lane(&paint_columns, index);
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 < source.lane_count() && source.element(index + 1).unwrap().had_clip_enabled();
if next_clips && mask_element_and_transform.is_none() {
mask_element_and_transform = Some((element, transform, reach));
render_element_vello(element, reach, scene, transform, context, render_params);
} else if let Some((mask_element, transform_mask, mask_reach)) = 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);
render_element_vello(mask_element, mask_reach, 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,
);
}
render_element_vello(element, reach, scene, transform, context, render_params);
if matches!(bounds, RenderBoundingBox::Rectangle(_)) {
scene.pop_layer();
scene.pop_layer();
}
} else {
render_element_vello(element, reach, scene, transform, context, render_params);
}
if layer {
scene.pop_layer();
}
}
}
fn collect_graphic_metadata<'e, S: LaneSource>>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) {
collect_graphic_metadata_with(source, PaintReach::NONE, metadata, footprint, element_id)
}
fn collect_graphic_metadata_with<'a, 'e, S: LaneSource>>(
source: &'a S,
inherited: PaintReach<'a>,
metadata: &mut RenderMetadata,
footprint: Footprint,
element_id: Option,
) {
let paint_columns = PaintColumns::new(source);
for index in 0..source.lane_count() {
let item_transform: DAffine2 = source.attr::(index);
let layer_path: &[NodeId] = source.attr::(index);
let layer = layer_path.last().copied();
let element = source.element(index).unwrap();
let reach = inherited.for_lane(&paint_columns, index);
let mut footprint = footprint;
footprint.transform *= item_transform;
if let Some(element_id) = layer {
collect_element_metadata(element, reach, item_transform, layer, metadata, footprint, Some(element_id));
} else {
// Recurse through anonymous wrapper items to reach nested content with editor:layer_path tags
collect_element_metadata(element, reach, item_transform, layer, 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..source.lane_count() {
let item_transform: DAffine2 = source.attr::(index);
let element = source.element(index).unwrap();
let reach = inherited.for_lane(&paint_columns, index);
let mut new_click_targets = Vec::new();
add_element_upstream_click_targets(element, reach, &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();
add_element_upstream_outline_targets(element, reach, &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_graphic_upstream_click_targets<'e, S: LaneSource>>(source: &S, click_targets: &mut Vec) {
add_graphic_upstream_click_targets_with(source, PaintReach::NONE, click_targets)
}
fn add_graphic_upstream_click_targets_with<'a, 'e, S: LaneSource>>(source: &'a S, inherited: PaintReach<'a>, click_targets: &mut Vec) {
let paint_columns = PaintColumns::new(source);
for index in 0..source.lane_count() {
let item_transform: DAffine2 = source.attr::(index);
let element = source.element(index).unwrap();
let reach = inherited.for_lane(&paint_columns, index);
let mut new_click_targets = Vec::new();
add_element_upstream_click_targets(element, reach, &mut new_click_targets);
for click_target in new_click_targets.iter_mut() {
click_target.apply_transform(item_transform)
}
click_targets.extend(new_click_targets);
}
}
fn add_graphic_upstream_outline_targets<'e, S: LaneSource>>(source: &S, outlines: &mut Vec) {
add_graphic_upstream_outline_targets_with(source, PaintReach::NONE, outlines)
}
fn add_graphic_upstream_outline_targets_with<'a, 'e, S: LaneSource>>(source: &'a S, inherited: PaintReach<'a>, outlines: &mut Vec) {
let paint_columns = PaintColumns::new(source);
for index in 0..source.lane_count() {
let item_transform: DAffine2 = source.attr::(index);
let element = source.element(index).unwrap();
let reach = inherited.for_lane(&paint_columns, index);
let mut new_outlines = Vec::new();
add_element_upstream_outline_targets(element, reach, &mut new_outlines);
for outline in new_outlines.iter_mut() {
outline.apply_transform(item_transform)
}
outlines.extend(new_outlines);
}
}
fn graphic_contains_artboard<'e, S: LaneSource>>(source: &S) -> bool {
(0..source.lane_count()).any(|index| source.element(index).is_some_and(|element| element.contains_artboard()))
}
impl Render for List> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_graphic_svg(self, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_graphic_vello(self, scene, transform, context, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) {
collect_graphic_metadata(self, metadata, footprint, element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec) {
add_graphic_upstream_click_targets(self, click_targets)
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec) {
add_graphic_upstream_outline_targets(self, outlines)
}
fn contains_artboard(&self) -> bool {
graphic_contains_artboard(self)
}
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.last().copied());
}
}
}
fn render_vector_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
for index in 0..source.lane_count() {
let Some(vector) = source.element(index) else { continue };
let item_transform: DAffine2 = source.attr::(index);
let blend_mode_attr: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform));
let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform);
let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform);
let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse());
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = vector.bounding_box().unwrap_or_default();
let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds);
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 = paint_graphics::(source, index);
let fill_graphic = fill_graphic_list.and_then(|l| l.element(0));
let stroke_graphic_list = paint_graphics::(source, index);
let stroke_graphic = stroke_graphic_list.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,
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,
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.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
.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
.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,
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
render_params,
);
}
}
}
fn render_vector_vello>(source: &S, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
for index in 0..source.lane_count() {
use graphic_types::vector_types::vector;
let Some(element) = source.element(index) else { continue };
let item_transform: DAffine2 = source.attr::(index);
let blend_mode_attr: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
let multiplied_transform = parent_transform * item_transform;
let has_real_stroke = element.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform));
let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform);
let mut element_transform = set_stroke_transform
.map(|stroke_transform| multiplied_transform * stroke_transform.inverse())
.unwrap_or(DAffine2::IDENTITY);
if let Some(alignment_transform) = render_params.alignment_parent_transform {
applied_stroke_transform = alignment_transform;
element_transform = if transform_is_invertible(alignment_transform) {
multiplied_transform * alignment_transform.inverse()
} else {
multiplied_transform
};
}
let layer_bounds = element.bounding_box().unwrap_or_default();
let mut path = kurbo::BezPath::new();
for mut bezpath in element.stroke_bezpath_iter() {
bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
for element in bezpath {
path.push(element);
}
}
let fill_graphic_list = paint_graphics::(source, index);
let stroke_graphic_list = paint_graphics::(source, index);
// If we're using opacity or a blend mode, we need to push a layer
let blend_mode = match render_params.render_mode {
RenderMode::Outline => peniko::Mix::Normal,
_ => blend_mode_attr.to_peniko(),
};
let mut layer = false;
// Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path).
// Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since
// the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down.
let stroke = element.stroke.as_ref();
let stroke_fully_transparent = stroke_graphic_list.is_none_or(|l| l.element(0).is_none_or(|g| g.is_fully_transparent()));
let can_draw_aligned_stroke = !stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed());
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. || blend_mode_attr != BlendMode::default() {
layer = true;
// `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`.
// Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform.
let (_, smallest_scale) = singular_values(applied_stroke_transform);
let stroke_inflation = stroke.map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke));
let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. };
let quad = Quad::from_box(layer_bounds).inflate(inflate_amount);
let layer_bounds = quad.bounding_box();
scene.push_layer(
peniko::Fill::NonZero,
peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver),
opacity,
kurbo::Affine::new(multiplied_transform.to_cols_array()),
&kurbo::Rect::new(layer_bounds[0].x, layer_bounds[0].y, layer_bounds[1].x, layer_bounds[1].y),
);
}
let use_layer = can_draw_aligned_stroke;
let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| {
let Some(fill_graphic) = fill_graphic_list 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(color) => {
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(gradient) => {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(fill_graphic, paint_index, gradient), &multiplied_transform) else {
continue;
};
let inverse_element_transform = if transform_is_invertible(element_transform) {
element_transform.inverse()
} else {
Default::default()
};
let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path);
}
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Group(_) => {
scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path);
paint.render_to_vello(scene, multiplied_transform, context, render_params);
scene.pop_layer();
}
};
}
};
// Branching vectors without regions (e.g. mesh grids) need face-by-face fill rendering.
let use_face_fill = element.use_face_fill();
let do_fill = |scene: &mut Scene, context: &mut RenderContext| {
if use_face_fill {
for mut face_path in element.construct_faces().filter(|face| face.area() >= 0.) {
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
let mut kurbo_path = kurbo::BezPath::new();
for element in face_path {
kurbo_path.push(element);
}
do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero);
}
} else if element.is_branching() {
do_fill_path(scene, context, &path, peniko::Fill::EvenOdd);
} else {
do_fill_path(scene, context, &path, peniko::Fill::NonZero);
}
};
let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| {
let Some(stroke_graphic_list) = stroke_graphic_list 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(color) => {
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(gradient) => {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(stroke_graphic_list, paint_index, gradient), &multiplied_transform) else {
continue;
};
let inverse_element_transform = if transform_is_invertible(element_transform) {
element_transform.inverse()
} else {
Default::default()
};
let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array());
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path);
}
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Group(_) => {
let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01);
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked);
stroke_graphic.render_to_vello(scene, multiplied_transform, context, render_params);
scene.pop_layer();
}
};
}
};
// Render the path
match render_params.render_mode {
RenderMode::Outline => {
let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params);
scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path);
}
_ => {
if use_layer {
let mut cloned_element = element.clone();
cloned_element.stroke = None;
// The mask must draw at full alpha so `SrcOut` fully zeroes the path interior.
// The outer opacity/blend layer (above) handles the user-set opacity.
let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
let vector_list = List::new_from_item(mask_item);
let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds);
// This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed
let inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true));
let (largest_scale, _) = singular_values(applied_stroke_transform);
let quad = Quad::from_box(bounds).inflate(inflation * largest_scale);
let bounds = quad.bounding_box();
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
let compose = if stroke.is_some_and(|x| x.align == StrokeAlign::Outside) {
peniko::Compose::SrcOut
} else {
peniko::Compose::SrcIn
};
if wants_stroke_below {
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform));
scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect);
do_stroke(scene, 2., context);
scene.pop_layer();
scene.pop_layer();
do_fill(scene, context);
} else {
// Fill first (unclipped), then stroke (clipped) above
do_fill(scene, context);
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform));
scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect);
do_stroke(scene, 2., context);
scene.pop_layer();
scene.pop_layer();
}
} else {
// Non-aligned strokes or open paths: default order behavior
enum Op {
Fill,
Stroke,
}
let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) {
true => [Op::Stroke, Op::Fill],
false => [Op::Fill, Op::Stroke], // Default
};
for operation in &order {
match operation {
Op::Fill => do_fill(scene, context),
Op::Stroke => do_stroke(scene, 1., context),
}
}
}
}
}
// If we pushed a layer for opacity or a blend mode, we need to pop it
if layer {
scene.pop_layer();
}
}
}
fn collect_vector_metadata>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) {
// Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph.
// Targets are baked relative to item 0's transform since `Graphic::collect_metadata` records that as `local_transforms[element_id]`.
let item_zero_transform: DAffine2 = if source.lane_count() > 0 { source.attr::(0) } else { DAffine2::IDENTITY };
let item_zero_inverse = if transform_is_invertible(item_zero_transform) {
item_zero_transform.inverse()
} else {
DAffine2::IDENTITY
};
let mut accumulated_click_targets: HashMap>> = HashMap::new();
let mut accumulated_outlines: HashMap>> = HashMap::new();
for index in 0..source.lane_count() {
let Some(element) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::(index);
let layer_path: &[NodeId] = source.attr::(index);
let layer = layer_path.last().copied();
if let Some(element_id) = caller_element_id.or(layer) {
// When recovering element_id from the item's editor:layer_path tag (because the caller
// passed None), also store the transform metadata that Graphic::collect_metadata
// normally provides but skipped due to the None element_id.
if caller_element_id.is_none() {
metadata.upstream_footprints.entry(element_id).or_insert(footprint);
metadata.local_transforms.entry(element_id).or_insert(item_zero_transform);
}
// Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes)
let click_target_vector = source.attr::(index).unwrap_or(element);
let item_relative_transform = item_zero_inverse * transform;
let mut click_targets_unwrapped = Vec::new();
extend_targets_from_vector(&mut click_targets_unwrapped, source, index, click_target_vector, item_relative_transform);
accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new));
// Outlines always use source geometry so the visual outline reflects actual letterforms
let mut outlines_unwrapped = Vec::new();
extend_targets_from_vector(&mut outlines_unwrapped, source, index, element, item_relative_transform);
accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new));
// Source geometry (not the click-target override) so editing tools work on letterforms.
// Recorded together with `vector_data` from the same (first) row so stroke geometry stays consistent with the paint.
// Only item 0 is recorded since editing tools can only target a single item currently.
// If that row has no paint attribute, none is recorded.
if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) {
e.insert(Arc::new(element.clone()));
if let Some(fill_graphic) = source.attr::(index).filter(|list| is_paint_present(list)) {
metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.clone()));
}
if let Some(stroke_graphic) = source.attr::(index).filter(|list| is_paint_present(list)) {
metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.clone()));
}
}
// Surface `editor:text_frame` for the Text tool's drag cage
if let Some(frame) = source.try_attr::(index) {
metadata.text_frames.entry(element_id).or_insert(frame);
}
}
// If this item carries a snapshot of upstream graphic content (e.g. it was produced by Boolean Operation,
// Combine Paths, Morph, or any other destructive merge), recurse into that snapshot so the editor can
// surface the original child layers' click targets.
if let Some(upstream_nested_layers) = source.attr::(index).filter(|layers| !layers.is_empty()) {
let mut upstream_footprint = footprint;
upstream_footprint.transform *= transform;
upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None);
}
}
// Overwrite with the full accumulated set (not just item 0's contribution)
for (element_id, targets) in accumulated_click_targets {
metadata.click_targets.insert(element_id, targets);
}
for (element_id, targets) in accumulated_outlines {
metadata.outlines.insert(element_id, targets);
}
}
fn add_vector_upstream_click_targets>(source: &S, click_targets: &mut Vec) {
for index in 0..source.lane_count() {
let Some(element) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::(index);
// Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes)
let vector = source.attr::(index).unwrap_or(element);
extend_targets_from_vector(click_targets, source, index, vector, transform);
}
}
fn add_vector_upstream_outline_targets>(source: &S, outlines: &mut Vec) {
// Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms
for index in 0..source.lane_count() {
let Some(element) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::(index);
extend_targets_from_vector(outlines, source, index, element, transform);
}
}
impl Render for List {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_vector_svg(self, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_vector_vello(self, scene, parent_transform, context, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) {
collect_vector_metadata(self, metadata, footprint, caller_element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec) {
add_vector_upstream_click_targets(self, click_targets)
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec) {
add_vector_upstream_outline_targets(self, outlines)
}
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, source: &S, index: usize, geometry: &Vector, transform: DAffine2) {
let filled = has_paint::(source, index);
let mut subpaths: Vec> = geometry.stroke_bezier_paths().collect();
let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed());
// 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)
})
}
fn render_raster_cpu_svg>>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
for index in 0..source.lane_count() {
let Some(image) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::(index);
let blend_mode_attr: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
if image.data.is_empty() {
continue;
}
if 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_raster_cpu_vello> + BoundingBox>(source: &S, scene: &mut Scene, transform: DAffine2, render_params: &RenderParams) {
for index in 0..source.lane_count() {
let Some(image) = source.element(index) else { continue };
if image.data.is_empty() {
continue;
}
let blend_mode_attr: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
let blend_mode = blend_mode_attr.to_peniko();
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let mut layer = false;
if (opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()))
&& let RenderBoundingBox::Rectangle(bounds) = source.bounding_box(transform, false)
{
let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver);
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect);
layer = true;
}
let transform_attribute: DAffine2 = source.attr::(index);
if let RenderMode::Outline = render_params.render_mode {
let outline_transform: DAffine2 = transform * transform_attribute;
draw_raster_outline(scene, &outline_transform, render_params);
if layer {
scene.pop_layer();
}
continue;
}
let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64));
let image_brush = peniko::ImageBrush::new(peniko::ImageData {
data: image.to_flat_u8().0.into(),
format: peniko::ImageFormat::Rgba8,
width: image.width,
height: image.height,
alpha_type: peniko::ImageAlphaType::Alpha,
})
.with_extend(peniko::Extend::Repeat);
scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array()));
if layer {
scene.pop_layer();
}
}
}
fn collect_raster_metadata(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) {
let Some(element_id) = element_id else { return };
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]);
metadata.upstream_footprints.insert(element_id, footprint);
// TODO: Find a way to handle more than one item of the `List>`
if source.lane_count() > 0 {
let transform: DAffine2 = source.attr::(0);
metadata.local_transforms.insert(element_id, transform);
// The snapshot's children already match `footprint`, so `transform` (the rasterization area) must not be applied.
if let Some(upstream_nested_layers) = source.attr::(0).filter(|layers| !layers.is_empty()) {
upstream_nested_layers.collect_metadata(metadata, footprint, None);
}
}
}
fn add_raster_upstream_click_targets(click_targets: &mut Vec) {
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
}
impl Render for List> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_raster_cpu_svg(self, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _: &mut RenderContext, render_params: &RenderParams) {
render_raster_cpu_vello(self, scene, transform, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) {
collect_raster_metadata(self, metadata, footprint, element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec) {
add_raster_upstream_click_targets(click_targets)
}
}
static LAZY_ARC_VEC_ZERO_U8: LazyLock>> = LazyLock::new(|| Arc::new(Vec::new()));
fn render_raster_gpu_vello> + BoundingBox>(source: &S, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
for index in 0..source.lane_count() {
let Some(raster) = source.element(index) else { continue };
let blend_mode_attr: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
let clip_attr: bool = source.attr::(index);
let blend_mode = match render_params.render_mode {
RenderMode::Outline => peniko::Mix::Normal,
_ => blend_mode_attr.to_peniko(),
};
let mut layer = false;
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let any_nondefault = blend_mode_attr != BlendMode::default() || opacity < 1. || clip_attr;
if (render_params.render_mode != RenderMode::Outline && any_nondefault)
&& let RenderBoundingBox::Rectangle(bounds) = source.bounding_box(transform, true)
{
let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver);
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect);
layer = true;
}
let transform_attribute: DAffine2 = source.attr::(index);
if let RenderMode::Outline = render_params.render_mode {
let outline_transform = transform * transform_attribute;
draw_raster_outline(scene, &outline_transform, render_params);
if layer {
scene.pop_layer();
}
continue;
}
let width = raster.data().width();
let height = raster.data().height();
let image = peniko::ImageBrush::new(peniko::ImageData {
data: peniko::Blob::new(LAZY_ARC_VEC_ZERO_U8.deref().clone()),
format: peniko::ImageFormat::Rgba8,
width,
height,
alpha_type: peniko::ImageAlphaType::Alpha,
})
.with_extend(peniko::Extend::Repeat);
let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64));
scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array()));
context.resource_overrides.push((image, raster.texture.clone()));
if layer {
scene.pop_layer()
}
}
}
impl Render for List> {
fn render_svg(&self, _render: &mut SvgRender, _render_params: &RenderParams) {
log::warn!("tried to render texture as an svg");
}
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_raster_gpu_vello(self, scene, transform, context, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) {
collect_raster_metadata(self, metadata, footprint, element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec) {
add_raster_upstream_click_targets(click_targets)
}
}
// 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.
fn render_color_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
{
for index in 0..source.lane_count() {
let Some(color) = source.element(index) else { continue };
let blend_mode: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
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_color_vello>(source: &S, scene: &mut Scene, render_params: &RenderParams) {
{
use vello::peniko;
for index in 0..source.lane_count() {
let Some(color) = source.element(index) else { continue };
let blend_mode_attr: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
let 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) {
render_color_svg(self, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, _parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
render_color_vello(self, scene, render_params)
}
}
fn render_gradient_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
// For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`.
// The viewBox crops the overshoot. Canvas rendering keeps the polyline path since Chrome rejects rects larger than ~20 million.
let thumbnail_rect = if render_params.thumbnail {
let truncated_size = render_params.footprint.resolution.as_dvec2();
let margin = DVec2::ONE;
Some((render_params.footprint.transform.translation - margin / 2., truncated_size + margin))
} else {
None
};
for index in 0..source.lane_count() {
let Some(gradient) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::(index);
let blend_mode: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
let settings = GradientSettings::from_lane_attributes(source, index);
let gradient_form: GradientForm = source.attr::(index);
let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" };
render.leaf_tag(tag, |attributes| {
if let Some((min, size)) = thumbnail_rect {
attributes.push("x", min.x.to_string());
attributes.push("y", min.y.to_string());
attributes.push("width", size.x.to_string());
attributes.push("height", size.y.to_string());
} else {
// Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses
// precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning.
// 1e7 stays under that limit while still being far larger than any practical document extent.
const MAX: f64 = 1e7;
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
}
let (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 spread_method_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_gradient_vello>(source: &S, scene: &mut Scene, parent_transform: DAffine2, render_params: &RenderParams) {
use vello::peniko;
if let RenderMode::Outline = render_params.render_mode {
return;
}
for index in 0..source.lane_count() {
let Some(gradient) = source.element(index) else { continue };
let settings = GradientSettings::from_lane_attributes(source, index);
let gradient_form: GradientForm = source.attr::(index);
let transform: DAffine2 = source.attr::(index);
let blend_mode_attr: BlendMode = source.attr::(index);
let opacity_attr: f64 = source.attr::(index);
let opacity_fill_attr: f64 = source.attr::(index);
let gradient_transform = parent_transform * transform;
let blend_mode = blend_mode_attr.to_peniko();
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let (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();
}
}
}
/// The control geometry of each gradient lane, transformed by `lane_transform`.
fn gradient_control_targets(source: &S, lane_transform: impl Fn(DAffine2) -> DAffine2, clickable_only: bool) -> Vec {
(0..source.lane_count())
.filter_map(|index| {
let gradient_form = source.attr::(index);
if clickable_only && !gradient_control_interior_is_clickable(gradient_form) {
return None;
}
let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.);
target.apply_transform(lane_transform(source.attr::(index)));
Some(target)
})
.collect()
}
fn collect_gradient_metadata(source: &S, metadata: &mut RenderMetadata, element_id: Option) {
let Some(element_id) = element_id else { return };
if source.lane_count() == 0 {
return;
}
// Targets are baked relative to lane 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]`
let lane_zero_transform = source.attr::(0);
let lane_zero_inverse = if transform_is_invertible(lane_zero_transform) {
lane_zero_transform.inverse()
} else {
DAffine2::IDENTITY
};
let outline_targets: Vec> = gradient_control_targets(source, |transform| lane_zero_inverse * transform, false).into_iter().map(Arc::new).collect();
let click_targets: Vec> = outline_targets
.iter()
.enumerate()
.filter(|(index, _)| gradient_control_interior_is_clickable(source.attr::(*index)))
.map(|(_, target)| target.clone())
.collect();
metadata.outlines.insert(element_id, outline_targets);
if !click_targets.is_empty() {
metadata.click_targets.insert(element_id, click_targets);
}
}
impl Render for List {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_gradient_svg(self, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
render_gradient_vello(self, scene, parent_transform, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option