mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
* Rename spread method to gradient spread so it matches the other gradient attribute names * Keep the legacy gradient struct's spread method field name matching its on-disk key
2675 lines
107 KiB
Rust
2675 lines
107 KiB
Rust
use crate::render_ext::{PaintTarget, RenderExt};
|
||
use crate::to_peniko::{BlendModeExt, ToPenikoColor};
|
||
use core_types::CacheHash;
|
||
use core_types::blending::BlendMode;
|
||
use core_types::bounds::BoundingBox;
|
||
use core_types::bounds::RenderBoundingBox;
|
||
use core_types::color::Color;
|
||
use core_types::color::SRGBA8;
|
||
use core_types::consts::DEFAULT_FONT_SIZE;
|
||
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, NodeIdPath};
|
||
use core_types::math::quad::Quad;
|
||
use core_types::render_complexity::RenderComplexity;
|
||
use core_types::transform::Footprint;
|
||
use core_types::uuid::{NodeId, generate_uuid};
|
||
use core_types::{
|
||
ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT,
|
||
ATTR_FONT_SIZE, ATTR_GRADIENT_SPREAD, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL,
|
||
ATTR_TEXT_ALIGN, ATTR_TRANSFORM,
|
||
};
|
||
use dyn_any::DynAny;
|
||
use glam::{DAffine2, DMat2, DVec2};
|
||
use graphene_hash::CacheHashWrapper;
|
||
use graphene_resource::Resource;
|
||
use graphic_types::graphic::{graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute};
|
||
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
|
||
use graphic_types::vector_types::gradient::{Gradient, GradientType};
|
||
use graphic_types::vector_types::subpath::Subpath;
|
||
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
|
||
use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
|
||
use graphic_types::{Artboard, Graphic, Vector};
|
||
use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts};
|
||
use num_traits::Zero;
|
||
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
|
||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||
use skrifa::raw::FontRef as SkrifaFontRef;
|
||
use skrifa::{GlyphId, MetadataProvider};
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::fmt::Write;
|
||
use std::hash::Hash;
|
||
use std::ops::Deref;
|
||
use std::sync::{Arc, LazyLock};
|
||
use vector_types::gradient::GradientSpread;
|
||
use vello::*;
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
enum MaskType {
|
||
Clip,
|
||
Mask,
|
||
}
|
||
|
||
impl MaskType {
|
||
fn to_attribute(self) -> String {
|
||
match self {
|
||
Self::Mask => "mask".to_string(),
|
||
Self::Clip => "clip-path".to_string(),
|
||
}
|
||
}
|
||
|
||
fn write_to_defs(self, svg_defs: &mut String, uuid: u64, svg_string: String) {
|
||
let id = format!("mask-{uuid}");
|
||
match self {
|
||
Self::Clip => write!(svg_defs, r##"<clipPath id="{id}">{svg_string}</clipPath>"##).unwrap(),
|
||
Self::Mask => write!(svg_defs, r##"<mask id="{id}" mask-type="alpha">{svg_string}</mask>"##).unwrap(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Mutable state used whilst rendering to an SVG
|
||
pub struct SvgRender {
|
||
pub svg: Vec<SvgSegment>,
|
||
pub svg_defs: String,
|
||
pub transform: DAffine2,
|
||
pub image_data: HashMap<CacheHashWrapper<Image<Color>>, 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 `<svg>...</svg>` tag with a `viewBox` and the `<defs />`
|
||
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#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:graphite="https://graphite.art" viewBox="{x} {y} {size_x} {size_y}"><defs>{defs}</defs>"#,
|
||
defs = &self.svg_defs
|
||
);
|
||
self.svg_defs = String::new();
|
||
self.svg.insert(0, svg_header.into());
|
||
self.svg.push("</svg>".into());
|
||
}
|
||
|
||
/// Wraps the SVG with `<svg><g transform="...">...</g></svg>`, which allows for rotation
|
||
pub fn wrap_with_transform(&mut self, transform: DAffine2, size: Option<DVec2>) {
|
||
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#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:graphite="https://graphite.art" {view_box}><defs>{defs}</defs><g{transform}>"#,
|
||
defs = &self.svg_defs
|
||
);
|
||
self.svg_defs = String::new();
|
||
self.svg.insert(0, svg_header.into());
|
||
self.svg.push("</g></svg>".into());
|
||
}
|
||
|
||
pub fn leaf_tag(&mut self, name: impl Into<SvgSegment>, 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<SvgSegment>) {
|
||
self.indent();
|
||
self.svg.push(content.into());
|
||
}
|
||
|
||
pub fn parent_tag(&mut self, name: impl Into<SvgSegment>, 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<CacheHashWrapper<Image<Color>>, 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<SvgRender> 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<DAffine2>,
|
||
pub aligned_strokes: bool,
|
||
pub override_paint_order: bool,
|
||
/// Are we rendering for a pattern content
|
||
pub inside_pattern: bool,
|
||
pub artboard_background: Option<Color>,
|
||
/// 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>) -> 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::<PointId>::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 `<path>` 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<Graphic>>,
|
||
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_type: GradientType) -> DAffine2 {
|
||
match gradient_type {
|
||
GradientType::Radial => transform,
|
||
GradientType::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,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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(gradient: &Gradient) -> peniko::ColorStops {
|
||
let mut peniko_stops = peniko::ColorStops::new();
|
||
|
||
for (position, color, _) in gradient.interpolated_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
|
||
}
|
||
|
||
fn create_peniko_gradient_brush(gradient_list: &List<Gradient>, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
|
||
let stops = gradient_list.element(0)?;
|
||
|
||
let gradient_type: GradientType = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0);
|
||
let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
|
||
let gradient_spread: GradientSpread = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, 0);
|
||
|
||
let peniko_stops = peniko_color_stops(stops);
|
||
|
||
// 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::ZERO, DVec2::X, gradient_placement(multiplied_transform * gradient_transform, gradient_type));
|
||
|
||
let brush = peniko::Brush::Gradient(peniko::Gradient {
|
||
kind: match gradient_type {
|
||
GradientType::Linear => peniko::LinearGradientPosition {
|
||
start: to_point(start),
|
||
end: to_point(end),
|
||
}
|
||
.into(),
|
||
GradientType::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: match gradient_spread {
|
||
GradientSpread::Pad => peniko::Extend::Pad,
|
||
GradientSpread::Reflect => peniko::Extend::Reflect,
|
||
GradientSpread::Repeat => peniko::Extend::Repeat,
|
||
},
|
||
stops: peniko_stops,
|
||
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied,
|
||
..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<NodeId, Footprint>,
|
||
pub local_transforms: HashMap<NodeId, DAffine2>,
|
||
pub first_element_source_id: HashMap<NodeId, Option<NodeId>>,
|
||
pub click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
|
||
/// 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<NodeId, Vec<Arc<ClickTarget>>>,
|
||
/// 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<NodeId, DAffine2>,
|
||
pub clip_targets: HashSet<NodeId>,
|
||
pub vector_data: HashMap<NodeId, Arc<Vector>>,
|
||
/// Per-layer `ATTR_FILL` row attribute, exposed so message handlers can read it.
|
||
#[cfg_attr(feature = "serde", serde(skip))]
|
||
pub fill_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
|
||
/// Per-layer `ATTR_STROKE` row attribute, exposed so message handlers can read it.
|
||
#[cfg_attr(feature = "serde", serde(skip))]
|
||
pub stroke_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
|
||
pub backgrounds: Vec<Background>,
|
||
}
|
||
|
||
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<ClickTarget>) {}
|
||
|
||
/// Like `add_upstream_click_targets` but for visual outlines. `List<Vector>` overrides this to ignore `editor:click_target` so outlines reflect the actual geometry.
|
||
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
|
||
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<NodeId>)>, current_path: Option<NodeId>) {}
|
||
|
||
/// 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<NodeId>) {}
|
||
|
||
fn contains_artboard(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
fn new_ids_from_hash(&mut self, _reference: Option<NodeId>) {}
|
||
}
|
||
|
||
impl Render for Graphic {
|
||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||
match self {
|
||
Graphic::None => (),
|
||
Graphic::Graphic(list) => list.render_svg(render, render_params),
|
||
Graphic::Vector(list) => list.render_svg(render, render_params),
|
||
Graphic::RasterCPU(list) => list.render_svg(render, render_params),
|
||
Graphic::RasterGPU(_) => (),
|
||
Graphic::Color(list) => list.render_svg(render, render_params),
|
||
Graphic::Gradient(list) => list.render_svg(render, render_params),
|
||
Graphic::Text(list) => list.render_svg(render, render_params),
|
||
}
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
|
||
match self {
|
||
Graphic::None => (),
|
||
Graphic::Graphic(list) => list.render_to_vello(scene, transform, context, render_params),
|
||
Graphic::Vector(list) => list.render_to_vello(scene, transform, context, render_params),
|
||
Graphic::RasterCPU(list) => list.render_to_vello(scene, transform, context, render_params),
|
||
Graphic::RasterGPU(list) => list.render_to_vello(scene, transform, context, render_params),
|
||
Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params),
|
||
Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params),
|
||
Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params),
|
||
}
|
||
}
|
||
|
||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
|
||
if let Some(element_id) = element_id {
|
||
match self {
|
||
Graphic::None => {}
|
||
Graphic::Graphic(_) => {
|
||
metadata.upstream_footprints.insert(element_id, footprint);
|
||
}
|
||
Graphic::Vector(list) => {
|
||
metadata.upstream_footprints.insert(element_id, footprint);
|
||
// TODO: Find a way to handle more than the first item
|
||
if !list.is_empty() {
|
||
let layer_path: List<NodeId> = list.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).0;
|
||
let layer = layer_path.iter_element_values().next_back().copied();
|
||
let transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
|
||
|
||
metadata.first_element_source_id.insert(element_id, layer);
|
||
metadata.local_transforms.insert(element_id, transform);
|
||
}
|
||
}
|
||
Graphic::RasterCPU(list) => {
|
||
metadata.upstream_footprints.insert(element_id, footprint);
|
||
|
||
// TODO: Find a way to handle more than the first item
|
||
if !list.is_empty() {
|
||
metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0));
|
||
}
|
||
}
|
||
Graphic::RasterGPU(list) => {
|
||
metadata.upstream_footprints.insert(element_id, footprint);
|
||
|
||
// TODO: Find a way to handle more than the first item
|
||
if !list.is_empty() {
|
||
metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0));
|
||
}
|
||
}
|
||
Graphic::Color(list) => {
|
||
metadata.upstream_footprints.insert(element_id, footprint);
|
||
|
||
// TODO: Find a way to handle more than the first item
|
||
if !list.is_empty() {
|
||
metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0));
|
||
}
|
||
}
|
||
Graphic::Gradient(list) => {
|
||
metadata.upstream_footprints.insert(element_id, footprint);
|
||
|
||
// TODO: Find a way to handle more than the first item
|
||
if !list.is_empty() {
|
||
metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0));
|
||
}
|
||
}
|
||
Graphic::Text(list) => {
|
||
metadata.upstream_footprints.insert(element_id, footprint);
|
||
|
||
// TODO: Find a way to handle more than the first item
|
||
if !list.is_empty() {
|
||
metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
match self {
|
||
Graphic::None => (),
|
||
Graphic::Graphic(list) => list.collect_metadata(metadata, footprint, element_id),
|
||
Graphic::Vector(list) => list.collect_metadata(metadata, footprint, element_id),
|
||
Graphic::RasterCPU(list) => list.collect_metadata(metadata, footprint, element_id),
|
||
Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id),
|
||
Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id),
|
||
Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id),
|
||
Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id),
|
||
}
|
||
}
|
||
|
||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||
match self {
|
||
Graphic::None => (),
|
||
Graphic::Graphic(list) => list.add_upstream_click_targets(click_targets),
|
||
Graphic::Vector(list) => list.add_upstream_click_targets(click_targets),
|
||
Graphic::RasterCPU(list) => list.add_upstream_click_targets(click_targets),
|
||
Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets),
|
||
Graphic::Color(list) => list.add_upstream_click_targets(click_targets),
|
||
Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets),
|
||
Graphic::Text(list) => list.add_upstream_click_targets(click_targets),
|
||
}
|
||
}
|
||
|
||
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
|
||
match self {
|
||
Graphic::None => (),
|
||
Graphic::Graphic(list) => list.add_upstream_outline_targets(outlines),
|
||
Graphic::Vector(list) => list.add_upstream_outline_targets(outlines),
|
||
Graphic::RasterCPU(list) => list.add_upstream_outline_targets(outlines),
|
||
Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines),
|
||
Graphic::Color(list) => list.add_upstream_outline_targets(outlines),
|
||
Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines),
|
||
Graphic::Text(list) => list.add_upstream_outline_targets(outlines),
|
||
}
|
||
}
|
||
|
||
fn contains_artboard(&self) -> bool {
|
||
match self {
|
||
Graphic::None => false,
|
||
Graphic::Graphic(list) => list.contains_artboard(),
|
||
Graphic::Vector(list) => list.contains_artboard(),
|
||
Graphic::RasterCPU(list) => list.contains_artboard(),
|
||
Graphic::RasterGPU(list) => list.contains_artboard(),
|
||
Graphic::Color(list) => list.contains_artboard(),
|
||
Graphic::Gradient(list) => list.contains_artboard(),
|
||
Graphic::Text(list) => list.contains_artboard(),
|
||
}
|
||
}
|
||
|
||
fn new_ids_from_hash(&mut self, reference: Option<NodeId>) {
|
||
match self {
|
||
Graphic::None => (),
|
||
Graphic::Graphic(list) => list.new_ids_from_hash(reference),
|
||
Graphic::Vector(list) => list.new_ids_from_hash(reference),
|
||
Graphic::RasterCPU(_) => (),
|
||
Graphic::RasterGPU(_) => (),
|
||
Graphic::Color(_) => (),
|
||
Graphic::Gradient(_) => (),
|
||
Graphic::Text(_) => (),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Reads the artboard metadata for the item at `index` from a `List<Artboard>`.
|
||
fn read_artboard_attributes(list: &List<Artboard>, index: usize) -> (DVec2, DVec2, Color, bool) {
|
||
let location: DVec2 = list.attribute_cloned_or_default(ATTR_LOCATION, index);
|
||
let dimensions: DVec2 = list.attribute_cloned_or_default(ATTR_DIMENSIONS, index);
|
||
let background: Color = list.attribute_cloned_or_default(ATTR_BACKGROUND, index);
|
||
let clip: bool = list.attribute_cloned_or_default(ATTR_CLIP, index);
|
||
(location, dimensions, background, clip)
|
||
}
|
||
|
||
impl Render for List<Artboard> {
|
||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||
for index in 0..self.len() {
|
||
let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue };
|
||
let (location, dimensions, background, clip) = read_artboard_attributes(self, index);
|
||
|
||
let x = location.x.min(location.x + dimensions.x);
|
||
let y = location.y.min(location.y + dimensions.y);
|
||
let width = dimensions.x.abs();
|
||
let height = dimensions.y.abs();
|
||
|
||
// Background
|
||
render.leaf_tag("rect", |attributes| {
|
||
attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
|
||
if background.a() < 1. {
|
||
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
|
||
}
|
||
attributes.push("x", x.to_string());
|
||
attributes.push("y", y.to_string());
|
||
attributes.push("width", width.to_string());
|
||
attributes.push("height", height.to_string());
|
||
});
|
||
|
||
// Artwork
|
||
render.parent_tag(
|
||
// SVG group tag
|
||
"g",
|
||
// Group tag attributes
|
||
|attributes| {
|
||
let matrix = format_transform_matrix(DAffine2::from_translation(location));
|
||
if !matrix.is_empty() {
|
||
attributes.push(ATTR_TRANSFORM, matrix);
|
||
}
|
||
|
||
if clip {
|
||
let id = format!("artboard-{}", generate_uuid());
|
||
let selector = format!("url(#{id})");
|
||
|
||
write!(
|
||
&mut attributes.0.svg_defs,
|
||
r##"<clipPath id="{id}"><rect x="0" y="0" width="{}" height="{}" /></clipPath>"##,
|
||
dimensions.x, dimensions.y,
|
||
)
|
||
.unwrap();
|
||
attributes.push("clip-path", selector);
|
||
}
|
||
},
|
||
// Artwork content
|
||
|render| {
|
||
let mut render_params = render_params.clone();
|
||
render_params.artboard_background = Some(background);
|
||
content.render_svg(render, &render_params);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
|
||
use vello::peniko;
|
||
|
||
for index in 0..self.len() {
|
||
let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue };
|
||
let (location, dimensions, background, clip) = read_artboard_attributes(self, index);
|
||
|
||
let [a, b] = [location, location + dimensions];
|
||
let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
|
||
|
||
let artboard_transform = kurbo::Affine::new(transform.to_cols_array());
|
||
|
||
let color = SRGBA8::from(background).to_peniko_color();
|
||
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
|
||
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
|
||
scene.pop_layer();
|
||
|
||
if clip {
|
||
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect);
|
||
}
|
||
|
||
// Since the content's transform is right multiplied in when rendering the content, we just need to right multiply by the artboard offset here.
|
||
let child_transform = transform * DAffine2::from_translation(location);
|
||
let mut render_params = render_params.clone();
|
||
render_params.artboard_background = Some(background);
|
||
content.render_to_vello(scene, child_transform, context, &render_params);
|
||
if clip {
|
||
scene.pop_layer();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, _element_id: Option<NodeId>) {
|
||
for index in 0..self.len() {
|
||
let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue };
|
||
let (location, dimensions, _background, clip) = read_artboard_attributes(self, index);
|
||
|
||
let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0;
|
||
let element_id = layer_path.iter_element_values().next_back().copied();
|
||
|
||
if let Some(element_id) = element_id {
|
||
let subpath = Subpath::new_rectangle(DVec2::ZERO, dimensions);
|
||
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]);
|
||
metadata.upstream_footprints.insert(element_id, footprint);
|
||
metadata.local_transforms.insert(element_id, DAffine2::from_translation(location));
|
||
if clip {
|
||
metadata.clip_targets.insert(element_id);
|
||
}
|
||
}
|
||
|
||
metadata.backgrounds.push(Background { location, dimensions });
|
||
|
||
let mut child_footprint = footprint;
|
||
child_footprint.transform *= DAffine2::from_translation(location);
|
||
content.collect_metadata(metadata, child_footprint, None);
|
||
}
|
||
}
|
||
|
||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||
for index in 0..self.len() {
|
||
let dimensions: DVec2 = self.attribute_cloned_or_default(ATTR_DIMENSIONS, index);
|
||
let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions);
|
||
click_targets.push(ClickTarget::new_with_subpath(subpath_rectangle, 0.));
|
||
}
|
||
}
|
||
|
||
fn contains_artboard(&self) -> bool {
|
||
!self.is_empty()
|
||
}
|
||
}
|
||
|
||
impl Render for List<Graphic> {
|
||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||
let mut mask_state = None;
|
||
|
||
for index in 0..self.len() {
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let element = self.element(index).unwrap();
|
||
|
||
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 < self.len() && self.element(index + 1).unwrap().had_clip_enabled();
|
||
|
||
if next_clips && mask_state.is_none() {
|
||
let uuid = generate_uuid();
|
||
let mask_type = if element.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask };
|
||
mask_state = Some((uuid, mask_type));
|
||
let mut svg = SvgRender::new();
|
||
element.render_svg(&mut svg, &render_params.for_clipper());
|
||
|
||
write!(&mut attributes.0.svg_defs, r##"{}"##, svg.svg_defs).unwrap();
|
||
mask_type.write_to_defs(&mut attributes.0.svg_defs, uuid, svg.svg.to_svg_string());
|
||
} else if let Some((uuid, mask_type)) = mask_state {
|
||
if !next_clips {
|
||
mask_state = None;
|
||
}
|
||
|
||
let id = format!("mask-{uuid}");
|
||
let selector = format!("url(#{id})");
|
||
|
||
attributes.push(mask_type.to_attribute(), selector);
|
||
}
|
||
},
|
||
|render| {
|
||
element.render_svg(render, render_params);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
|
||
let mut mask_element_and_transform = None;
|
||
|
||
for index in 0..self.len() {
|
||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let transform = transform * item_transform;
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let element = self.element(index).unwrap();
|
||
|
||
let mut layer = false;
|
||
|
||
let blend_mode = match render_params.render_mode {
|
||
RenderMode::Outline => peniko::Mix::Normal,
|
||
_ => blend_mode_attr.to_peniko(),
|
||
};
|
||
let mut bounds = RenderBoundingBox::None;
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
if opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()) {
|
||
bounds = element.bounding_box(transform, true);
|
||
|
||
if let RenderBoundingBox::Rectangle(bounds) = bounds {
|
||
scene.push_layer(
|
||
peniko::Fill::NonZero,
|
||
peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver),
|
||
opacity,
|
||
kurbo::Affine::IDENTITY,
|
||
&kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y),
|
||
);
|
||
layer = true;
|
||
}
|
||
}
|
||
|
||
let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled();
|
||
if next_clips && mask_element_and_transform.is_none() {
|
||
mask_element_and_transform = Some((element, transform));
|
||
|
||
element.render_to_vello(scene, transform, context, render_params);
|
||
} else if let Some((mask_element, transform_mask)) = mask_element_and_transform {
|
||
if !next_clips {
|
||
mask_element_and_transform = None;
|
||
}
|
||
if !layer {
|
||
bounds = element.bounding_box(transform, true);
|
||
}
|
||
|
||
if let RenderBoundingBox::Rectangle(bounds) = bounds {
|
||
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
|
||
|
||
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
|
||
mask_element.render_to_vello(scene, transform_mask, context, &render_params.for_clipper());
|
||
scene.push_layer(
|
||
peniko::Fill::NonZero,
|
||
peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn),
|
||
1.,
|
||
kurbo::Affine::IDENTITY,
|
||
&rect,
|
||
);
|
||
}
|
||
|
||
element.render_to_vello(scene, transform, context, render_params);
|
||
|
||
if matches!(bounds, RenderBoundingBox::Rectangle(_)) {
|
||
scene.pop_layer();
|
||
scene.pop_layer();
|
||
}
|
||
} else {
|
||
element.render_to_vello(scene, transform, context, render_params);
|
||
}
|
||
|
||
if layer {
|
||
scene.pop_layer();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
|
||
for index in 0..self.len() {
|
||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0;
|
||
let layer = layer_path.iter_element_values().next_back().copied();
|
||
let element = self.element(index).unwrap();
|
||
|
||
let mut footprint = footprint;
|
||
footprint.transform *= item_transform;
|
||
|
||
if let Some(element_id) = layer {
|
||
element.collect_metadata(metadata, footprint, Some(element_id));
|
||
} else {
|
||
// Recurse through anonymous wrapper items to reach nested content with editor:layer_path tags
|
||
element.collect_metadata(metadata, footprint, None);
|
||
}
|
||
}
|
||
|
||
if let Some(element_id) = element_id {
|
||
let mut all_upstream_click_targets = Vec::new();
|
||
let mut all_upstream_outlines = Vec::new();
|
||
|
||
for index in 0..self.len() {
|
||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let element = self.element(index).unwrap();
|
||
|
||
let mut new_click_targets = Vec::new();
|
||
element.add_upstream_click_targets(&mut new_click_targets);
|
||
|
||
for click_target in new_click_targets.iter_mut() {
|
||
click_target.apply_transform(item_transform)
|
||
}
|
||
|
||
all_upstream_click_targets.extend(new_click_targets);
|
||
|
||
let mut new_outlines = Vec::new();
|
||
element.add_upstream_outline_targets(&mut new_outlines);
|
||
for outline in new_outlines.iter_mut() {
|
||
outline.apply_transform(item_transform)
|
||
}
|
||
all_upstream_outlines.extend(new_outlines);
|
||
}
|
||
|
||
metadata.click_targets.insert(element_id, all_upstream_click_targets.into_iter().map(|x| x.into()).collect());
|
||
metadata.outlines.insert(element_id, all_upstream_outlines.into_iter().map(|x| x.into()).collect());
|
||
}
|
||
}
|
||
|
||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||
for index in 0..self.len() {
|
||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let element = self.element(index).unwrap();
|
||
let mut new_click_targets = Vec::new();
|
||
|
||
element.add_upstream_click_targets(&mut new_click_targets);
|
||
|
||
for click_target in new_click_targets.iter_mut() {
|
||
click_target.apply_transform(item_transform)
|
||
}
|
||
|
||
click_targets.extend(new_click_targets);
|
||
}
|
||
}
|
||
|
||
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
|
||
for index in 0..self.len() {
|
||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let element = self.element(index).unwrap();
|
||
let mut new_outlines = Vec::new();
|
||
|
||
element.add_upstream_outline_targets(&mut new_outlines);
|
||
|
||
for outline in new_outlines.iter_mut() {
|
||
outline.apply_transform(item_transform)
|
||
}
|
||
|
||
outlines.extend(new_outlines);
|
||
}
|
||
}
|
||
|
||
fn contains_artboard(&self) -> bool {
|
||
self.iter_element_values().any(|element| element.contains_artboard())
|
||
}
|
||
|
||
fn new_ids_from_hash(&mut self, _reference: Option<NodeId>) {
|
||
let (elements, layers) = self.element_and_attribute_slices_mut::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH);
|
||
for (element, layer) in elements.iter_mut().zip(layers.iter()) {
|
||
element.new_ids_from_hash(layer.0.iter_element_values().next_back().copied());
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Render for List<Vector> {
|
||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||
for index in 0..self.len() {
|
||
let Some(vector) = self.element(index) else { continue };
|
||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
|
||
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
|
||
let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
|
||
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform));
|
||
let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform);
|
||
let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform);
|
||
let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse());
|
||
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
|
||
let layer_bounds = vector.bounding_box().unwrap_or_default();
|
||
let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
|
||
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds);
|
||
|
||
let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]);
|
||
let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]);
|
||
|
||
let mut path = String::new();
|
||
|
||
for mut bezpath in vector.stroke_bezpath_iter() {
|
||
bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
|
||
path.push_str(bezpath.to_svg().as_str());
|
||
}
|
||
|
||
let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) {
|
||
MaskType::Clip
|
||
} else {
|
||
MaskType::Mask
|
||
};
|
||
|
||
let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL);
|
||
let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0));
|
||
|
||
let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE);
|
||
let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0));
|
||
|
||
let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed());
|
||
let can_draw_aligned_stroke = path_is_closed
|
||
&& vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
|
||
&& stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent());
|
||
let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip);
|
||
|
||
let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order;
|
||
let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow);
|
||
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
|
||
let use_face_fill = vector.use_face_fill();
|
||
|
||
if needs_separate_alignment_fill && !wants_stroke_below {
|
||
emit_svg_fill_path(
|
||
render,
|
||
path.clone(),
|
||
fill_graphic_list.as_deref(),
|
||
item_transform,
|
||
element_transform,
|
||
applied_stroke_transform,
|
||
bounds_matrix,
|
||
render_params,
|
||
);
|
||
}
|
||
|
||
let push_id = needs_separate_alignment_fill.then_some({
|
||
let id = format!("alignment-{}", generate_uuid());
|
||
|
||
let mut cloned_vector = vector.clone();
|
||
cloned_vector.stroke = None;
|
||
|
||
// The mask must draw at full alpha so the SVG `<mask>`/`<clipPath>` fully zeroes the path interior.
|
||
// The wrapping SVG group (above) handles the user-set opacity.
|
||
let mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform);
|
||
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
|
||
let vector_item = List::new_from_item(mask_item);
|
||
|
||
(id, mask_type, vector_item)
|
||
});
|
||
|
||
if use_face_fill {
|
||
for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) {
|
||
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
|
||
let face_d = face_path.to_svg();
|
||
|
||
emit_svg_fill_path(
|
||
render,
|
||
face_d,
|
||
fill_graphic_list.as_deref(),
|
||
item_transform,
|
||
element_transform,
|
||
applied_stroke_transform,
|
||
bounds_matrix,
|
||
render_params,
|
||
);
|
||
}
|
||
}
|
||
|
||
render.leaf_tag("path", |attributes| {
|
||
attributes.push("d", path.clone());
|
||
let matrix = format_transform_matrix(element_transform);
|
||
if !matrix.is_empty() {
|
||
attributes.push(ATTR_TRANSFORM, matrix);
|
||
}
|
||
|
||
let defs = &mut attributes.0.svg_defs;
|
||
if let Some((ref id, mask_type, ref vector_item)) = push_id {
|
||
let mut svg = SvgRender::new();
|
||
vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
|
||
let stroke = vector.stroke.as_ref().unwrap();
|
||
// `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed`
|
||
let (largest_scale, _) = singular_values(applied_stroke_transform);
|
||
let inflation = stroke.max_aabb_inflation(true) * largest_scale;
|
||
let quad = Quad::from_box(transformed_bounds).inflate(inflation);
|
||
let (x, y) = quad.top_left().into();
|
||
let (width, height) = (quad.bottom_right() - quad.top_left()).into();
|
||
|
||
write!(defs, r##"{}"##, svg.svg_defs).unwrap();
|
||
let rect = format!(r##"<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="white" />"##);
|
||
|
||
match mask_type {
|
||
MaskType::Clip => write!(defs, r##"<clipPath id="{id}">{}</clipPath>"##, svg.svg.to_svg_string()).unwrap(),
|
||
MaskType::Mask => write!(
|
||
defs,
|
||
r##"<mask id="{id}" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" x="{x}" y="{y}" width="{width}" height="{height}">{}{}</mask>"##,
|
||
rect,
|
||
svg.svg.to_svg_string()
|
||
)
|
||
.unwrap(),
|
||
}
|
||
}
|
||
|
||
let mut render_params = render_params.clone();
|
||
render_params.aligned_strokes = can_draw_aligned_stroke;
|
||
render_params.override_paint_order = override_paint_order;
|
||
|
||
let stroke_shape_attribute = vector
|
||
.stroke
|
||
.as_ref()
|
||
.map(|stroke| {
|
||
if stroke_graphic_list.as_deref().is_some_and(is_paint_present) {
|
||
stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke)
|
||
} else {
|
||
String::new()
|
||
}
|
||
})
|
||
.unwrap_or_default();
|
||
|
||
// Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback
|
||
let stroke_visible = vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent());
|
||
let stroke_attribute = if stroke_visible {
|
||
stroke_graphic_list
|
||
.as_deref()
|
||
.map(|list| {
|
||
// Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke.
|
||
// Only clipping-based paints need the stroke-inclusive bbox.
|
||
let paint_bounds = match list.element(0) {
|
||
Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) => bounds_matrix,
|
||
_ => stroke_bounds_matrix,
|
||
};
|
||
list.render(defs, item_transform, element_transform, applied_stroke_transform, paint_bounds, &render_params, PaintTarget::Stroke)
|
||
})
|
||
.unwrap_or_else(|| r#" stroke="none""#.to_string())
|
||
} else {
|
||
String::new()
|
||
};
|
||
|
||
let fill_attribute = if needs_separate_alignment_fill || use_face_fill {
|
||
r#" fill="none""#.to_string()
|
||
} else {
|
||
fill_graphic_list
|
||
.as_deref()
|
||
.map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill))
|
||
.unwrap_or_else(|| r#" fill="none""#.to_string())
|
||
};
|
||
|
||
if let Some((id, mask_type, _)) = push_id {
|
||
let selector = format!("url(#{id})");
|
||
attributes.push(mask_type.to_attribute(), selector);
|
||
}
|
||
attributes.push_val(fill_attribute);
|
||
attributes.push_val(stroke_shape_attribute);
|
||
attributes.push_val(stroke_attribute);
|
||
|
||
if vector.is_branching() && !use_face_fill {
|
||
attributes.push("fill-rule", "evenodd");
|
||
}
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
if opacity < 1. {
|
||
attributes.push("opacity", opacity.to_string());
|
||
}
|
||
|
||
if blend_mode_attr != BlendMode::default() {
|
||
attributes.push("style", blend_mode_attr.render());
|
||
}
|
||
});
|
||
|
||
// When splitting passes and stroke is below, draw the fill after the stroke.
|
||
if needs_separate_alignment_fill && wants_stroke_below {
|
||
emit_svg_fill_path(
|
||
render,
|
||
path.clone(),
|
||
fill_graphic_list.as_deref(),
|
||
item_transform,
|
||
element_transform,
|
||
applied_stroke_transform,
|
||
bounds_matrix,
|
||
render_params,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
|
||
for index in 0..self.len() {
|
||
use graphic_types::vector_types::vector;
|
||
|
||
let Some(element) = self.element(index) else { continue };
|
||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let multiplied_transform = parent_transform * item_transform;
|
||
let has_real_stroke = element.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
|
||
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform));
|
||
let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform);
|
||
let mut element_transform = set_stroke_transform
|
||
.map(|stroke_transform| multiplied_transform * stroke_transform.inverse())
|
||
.unwrap_or(DAffine2::IDENTITY);
|
||
if let Some(alignment_transform) = render_params.alignment_parent_transform {
|
||
applied_stroke_transform = alignment_transform;
|
||
element_transform = if transform_is_invertible(alignment_transform) {
|
||
multiplied_transform * alignment_transform.inverse()
|
||
} else {
|
||
multiplied_transform
|
||
};
|
||
}
|
||
let layer_bounds = element.bounding_box().unwrap_or_default();
|
||
|
||
let mut path = kurbo::BezPath::new();
|
||
for mut bezpath in element.stroke_bezpath_iter() {
|
||
bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
|
||
for element in bezpath {
|
||
path.push(element);
|
||
}
|
||
}
|
||
|
||
let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL);
|
||
let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE);
|
||
|
||
// If we're using opacity or a blend mode, we need to push a layer
|
||
let blend_mode = match render_params.render_mode {
|
||
RenderMode::Outline => peniko::Mix::Normal,
|
||
_ => blend_mode_attr.to_peniko(),
|
||
};
|
||
let mut layer = false;
|
||
|
||
// Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path).
|
||
// Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since
|
||
// the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down.
|
||
let stroke = element.stroke.as_ref();
|
||
let stroke_fully_transparent = stroke_graphic_list.as_ref().is_none_or(|l| l.element(0).is_none_or(|g| g.is_fully_transparent()));
|
||
let can_draw_aligned_stroke =
|
||
!stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed());
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
if opacity < 1. || blend_mode_attr != BlendMode::default() {
|
||
layer = true;
|
||
// `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`.
|
||
// Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform.
|
||
let (_, smallest_scale) = singular_values(applied_stroke_transform);
|
||
let stroke_inflation = stroke.map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke));
|
||
let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. };
|
||
let quad = Quad::from_box(layer_bounds).inflate(inflate_amount);
|
||
let layer_bounds = quad.bounding_box();
|
||
scene.push_layer(
|
||
peniko::Fill::NonZero,
|
||
peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver),
|
||
opacity,
|
||
kurbo::Affine::new(multiplied_transform.to_cols_array()),
|
||
&kurbo::Rect::new(layer_bounds[0].x, layer_bounds[0].y, layer_bounds[1].x, layer_bounds[1].y),
|
||
);
|
||
}
|
||
|
||
let use_layer = can_draw_aligned_stroke;
|
||
let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
|
||
|
||
let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| {
|
||
let Some(fill_graphic) = fill_graphic_list.as_deref() else { return };
|
||
|
||
for paint_index in 0..fill_graphic.len() {
|
||
let Some(paint) = fill_graphic.element(paint_index) else { continue };
|
||
match paint {
|
||
Graphic::None => continue,
|
||
Graphic::Color(list) => {
|
||
let Some(color) = list.element(0) else { continue };
|
||
|
||
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
|
||
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
|
||
}
|
||
Graphic::Gradient(list) => {
|
||
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else {
|
||
continue;
|
||
};
|
||
|
||
let inverse_element_transform = if transform_is_invertible(element_transform) {
|
||
element_transform.inverse()
|
||
} else {
|
||
Default::default()
|
||
};
|
||
let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array());
|
||
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path);
|
||
}
|
||
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => {
|
||
scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path);
|
||
paint.render_to_vello(scene, multiplied_transform, context, render_params);
|
||
scene.pop_layer();
|
||
}
|
||
};
|
||
}
|
||
};
|
||
|
||
// Branching vectors without regions (e.g. mesh grids) need face-by-face fill rendering.
|
||
let use_face_fill = element.use_face_fill();
|
||
let do_fill = |scene: &mut Scene, context: &mut RenderContext| {
|
||
if use_face_fill {
|
||
for mut face_path in element.construct_faces().filter(|face| face.area() >= 0.) {
|
||
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
|
||
let mut kurbo_path = kurbo::BezPath::new();
|
||
for element in face_path {
|
||
kurbo_path.push(element);
|
||
}
|
||
do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero);
|
||
}
|
||
} else if element.is_branching() {
|
||
do_fill_path(scene, context, &path, peniko::Fill::EvenOdd);
|
||
} else {
|
||
do_fill_path(scene, context, &path, peniko::Fill::NonZero);
|
||
}
|
||
};
|
||
|
||
let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| {
|
||
let Some(stroke_graphic_list) = stroke_graphic_list.as_deref() else { return };
|
||
let Some(stroke) = stroke else { return };
|
||
|
||
for paint_index in 0..stroke_graphic_list.len() {
|
||
let Some(stroke_graphic) = stroke_graphic_list.element(paint_index) else {
|
||
continue;
|
||
};
|
||
|
||
let cap = match stroke.cap {
|
||
StrokeCap::Butt => Cap::Butt,
|
||
StrokeCap::Round => Cap::Round,
|
||
StrokeCap::Square => Cap::Square,
|
||
};
|
||
let join = match stroke.join {
|
||
StrokeJoin::Miter => Join::Miter,
|
||
StrokeJoin::Bevel => Join::Bevel,
|
||
StrokeJoin::Round => Join::Round,
|
||
};
|
||
let dash_pattern = stroke.dash_lengths.iter().map(|l| l.max(0.)).collect();
|
||
let stroke = kurbo::Stroke {
|
||
width: stroke.weight * width_scale,
|
||
miter_limit: stroke.join_miter_limit,
|
||
join,
|
||
start_cap: cap,
|
||
end_cap: cap,
|
||
dash_pattern,
|
||
dash_offset: stroke.dash_offset,
|
||
};
|
||
|
||
if stroke.width <= 0. {
|
||
continue;
|
||
};
|
||
|
||
match stroke_graphic {
|
||
Graphic::None => continue,
|
||
Graphic::Color(list) => {
|
||
let Some(color) = list.element(0) else { continue };
|
||
let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
|
||
|
||
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path);
|
||
}
|
||
Graphic::Gradient(list) => {
|
||
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else {
|
||
continue;
|
||
};
|
||
let inverse_element_transform = if transform_is_invertible(element_transform) {
|
||
element_transform.inverse()
|
||
} else {
|
||
Default::default()
|
||
};
|
||
let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array());
|
||
|
||
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path);
|
||
}
|
||
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => {
|
||
let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01);
|
||
|
||
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked);
|
||
stroke_graphic.render_to_vello(scene, multiplied_transform, context, render_params);
|
||
scene.pop_layer();
|
||
}
|
||
};
|
||
}
|
||
};
|
||
|
||
// Render the path
|
||
match render_params.render_mode {
|
||
RenderMode::Outline => {
|
||
let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params);
|
||
|
||
scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path);
|
||
}
|
||
_ => {
|
||
if use_layer {
|
||
let mut cloned_element = element.clone();
|
||
cloned_element.stroke = None;
|
||
|
||
// The mask must draw at full alpha so `SrcOut` fully zeroes the path interior.
|
||
// The outer opacity/blend layer (above) handles the user-set opacity.
|
||
let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform);
|
||
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
|
||
let vector_list = List::new_from_item(mask_item);
|
||
|
||
let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds);
|
||
// This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed
|
||
let inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true));
|
||
let (largest_scale, _) = singular_values(applied_stroke_transform);
|
||
let quad = Quad::from_box(bounds).inflate(inflation * largest_scale);
|
||
let bounds = quad.bounding_box();
|
||
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
|
||
|
||
let compose = if stroke.is_some_and(|x| x.align == StrokeAlign::Outside) {
|
||
peniko::Compose::SrcOut
|
||
} else {
|
||
peniko::Compose::SrcIn
|
||
};
|
||
|
||
if wants_stroke_below {
|
||
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
|
||
vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform));
|
||
scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect);
|
||
|
||
do_stroke(scene, 2., context);
|
||
|
||
scene.pop_layer();
|
||
scene.pop_layer();
|
||
|
||
do_fill(scene, context);
|
||
} else {
|
||
// Fill first (unclipped), then stroke (clipped) above
|
||
do_fill(scene, context);
|
||
|
||
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
|
||
vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform));
|
||
scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect);
|
||
|
||
do_stroke(scene, 2., context);
|
||
|
||
scene.pop_layer();
|
||
scene.pop_layer();
|
||
}
|
||
} else {
|
||
// Non-aligned strokes or open paths: default order behavior
|
||
enum Op {
|
||
Fill,
|
||
Stroke,
|
||
}
|
||
|
||
let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) {
|
||
true => [Op::Stroke, Op::Fill],
|
||
false => [Op::Fill, Op::Stroke], // Default
|
||
};
|
||
|
||
for operation in &order {
|
||
match operation {
|
||
Op::Fill => do_fill(scene, context),
|
||
Op::Stroke => do_stroke(scene, 1., context),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we pushed a layer for opacity or a blend mode, we need to pop it
|
||
if layer {
|
||
scene.pop_layer();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
|
||
// Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph.
|
||
// Targets are baked relative to item 0's transform since `Graphic::collect_metadata` records that as `local_transforms[element_id]`.
|
||
let item_zero_transform: DAffine2 = if !self.is_empty() {
|
||
self.attribute_cloned_or_default(ATTR_TRANSFORM, 0)
|
||
} else {
|
||
DAffine2::IDENTITY
|
||
};
|
||
let item_zero_inverse = if transform_is_invertible(item_zero_transform) {
|
||
item_zero_transform.inverse()
|
||
} else {
|
||
DAffine2::IDENTITY
|
||
};
|
||
|
||
let mut accumulated_click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
|
||
let mut accumulated_outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
|
||
|
||
for index in 0..self.len() {
|
||
let Some(source) = self.element(index) else { continue };
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0;
|
||
let layer = layer_path.iter_element_values().next_back().copied();
|
||
|
||
if let Some(element_id) = caller_element_id.or(layer) {
|
||
// When recovering element_id from the item's editor:layer_path tag (because the caller
|
||
// passed None), also store the transform metadata that Graphic::collect_metadata
|
||
// normally provides but skipped due to the None element_id.
|
||
if caller_element_id.is_none() {
|
||
metadata.upstream_footprints.entry(element_id).or_insert(footprint);
|
||
metadata.local_transforms.entry(element_id).or_insert(item_zero_transform);
|
||
}
|
||
|
||
// Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes)
|
||
let click_target_vector = self.attribute::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source);
|
||
|
||
let item_relative_transform = item_zero_inverse * transform;
|
||
|
||
let mut click_targets_unwrapped = Vec::new();
|
||
extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform);
|
||
accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new));
|
||
|
||
// Outlines always use source geometry so the visual outline reflects actual letterforms
|
||
let mut outlines_unwrapped = Vec::new();
|
||
extend_targets_from_vector(&mut outlines_unwrapped, self, index, source, item_relative_transform);
|
||
accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new));
|
||
|
||
// Source geometry (not the click-target override) so editing tools work on letterforms.
|
||
// Recorded together with `vector_data` from the same (first) row so stroke geometry stays consistent with the paint.
|
||
// Only item 0 is recorded since editing tools can only target a single item currently.
|
||
// If that row has no paint attribute, none is recorded.
|
||
if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) {
|
||
e.insert(Arc::new(source.clone()));
|
||
|
||
if let Some(fill_graphic) = graphic_list_at(self, index, ATTR_FILL) {
|
||
metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.into_owned()));
|
||
}
|
||
if let Some(stroke_graphic) = graphic_list_at(self, index, ATTR_STROKE) {
|
||
metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.into_owned()));
|
||
}
|
||
}
|
||
|
||
// Surface `editor:text_frame` for the Text tool's drag cage
|
||
if let Some(&frame) = self.attribute::<DAffine2>(ATTR_EDITOR_TEXT_FRAME, index) {
|
||
metadata.text_frames.entry(element_id).or_insert(frame);
|
||
}
|
||
}
|
||
|
||
// If this item carries a snapshot of upstream graphic content (e.g. it was produced by Boolean Operation,
|
||
// Combine Paths, Morph, or any other destructive merge), recurse into that snapshot so the editor can
|
||
// surface the original child layers' click targets.
|
||
let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, index);
|
||
if !upstream_nested_layers.is_empty() {
|
||
let mut upstream_footprint = footprint;
|
||
upstream_footprint.transform *= transform;
|
||
upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None);
|
||
}
|
||
}
|
||
|
||
// Overwrite with the full accumulated set (not just item 0's contribution)
|
||
for (element_id, targets) in accumulated_click_targets {
|
||
metadata.click_targets.insert(element_id, targets);
|
||
}
|
||
for (element_id, targets) in accumulated_outlines {
|
||
metadata.outlines.insert(element_id, targets);
|
||
}
|
||
}
|
||
|
||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||
for index in 0..self.len() {
|
||
let Some(source) = self.element(index) else { continue };
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
|
||
// Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes)
|
||
let vector = self.attribute::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source);
|
||
|
||
extend_targets_from_vector(click_targets, self, index, vector, transform);
|
||
}
|
||
}
|
||
|
||
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
|
||
// Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms
|
||
for index in 0..self.len() {
|
||
let Some(source) = self.element(index) else { continue };
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
|
||
extend_targets_from_vector(outlines, self, index, source, transform);
|
||
}
|
||
}
|
||
|
||
fn new_ids_from_hash(&mut self, reference: Option<NodeId>) {
|
||
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<ClickTarget>, vector_list: &List<Vector>, index: usize, geometry: &Vector, transform: DAffine2) {
|
||
let filled = has_paint_at(vector_list, index, ATTR_FILL);
|
||
|
||
let mut subpaths: Vec<Subpath<_>> = 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<Item = ClickTarget> + '_ {
|
||
// Mark every point index touched by a segment endpoint in one `O(points + segments)` pass, avoiding a per-point `any_connected` scan
|
||
let mut connected = vec![false; vector.point_domain.len()];
|
||
for &point_index in vector.segment_domain.start_point().iter().chain(vector.segment_domain.end_point()) {
|
||
connected[point_index] = true;
|
||
}
|
||
|
||
vector.point_domain.ids().iter().enumerate().filter_map(move |(point_index, &point_id)| {
|
||
if connected[point_index] {
|
||
return None;
|
||
}
|
||
|
||
let anchor = vector.point_domain.position_from_id(point_id).unwrap_or_default();
|
||
let mut click_target = ClickTarget::new_with_free_point(FreePoint::new(point_id, anchor));
|
||
click_target.apply_transform(transform);
|
||
Some(click_target)
|
||
})
|
||
}
|
||
|
||
impl Render for List<Raster<CPU>> {
|
||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||
for index in 0..self.len() {
|
||
let Some(image) = self.element(index) else { continue };
|
||
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
|
||
if image.data.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
if render_params.to_canvas() {
|
||
let mut image_copy = image.clone();
|
||
image_copy.data_mut().map_pixels(|p| p.to_unassociated_alpha());
|
||
let id = *render.image_data.entry(CacheHashWrapper(image_copy.into_data())).or_insert_with(generate_uuid);
|
||
|
||
render.parent_tag(
|
||
"foreignObject",
|
||
|attributes| {
|
||
let size = DVec2::new(image.width as f64, image.height as f64);
|
||
|
||
let matrix = transform * DAffine2::from_scale(1. / size);
|
||
let matrix = format_transform_matrix(matrix);
|
||
if !matrix.is_empty() {
|
||
attributes.push(ATTR_TRANSFORM, matrix);
|
||
}
|
||
|
||
attributes.push("width", size.x.to_string());
|
||
attributes.push("height", size.y.to_string());
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
if opacity < 1. {
|
||
attributes.push("opacity", opacity.to_string());
|
||
}
|
||
|
||
if blend_mode_attr != BlendMode::default() {
|
||
attributes.push("style", blend_mode_attr.render());
|
||
}
|
||
},
|
||
|render| {
|
||
render.leaf_tag(
|
||
"img", // Must be a self-closing (void element) tag, so we can't use `div` or `span`, for example
|
||
|attributes| {
|
||
attributes.push("data-canvas-placeholder", id.to_string());
|
||
},
|
||
)
|
||
},
|
||
);
|
||
} else {
|
||
let base64_string = image.base64_string.clone().unwrap_or_else(|| {
|
||
use base64::Engine;
|
||
|
||
let output = image.to_png();
|
||
let preamble = "data:image/png;base64,";
|
||
let mut base64_string = String::with_capacity(preamble.len() + output.len() * 4);
|
||
base64_string.push_str(preamble);
|
||
base64::engine::general_purpose::STANDARD.encode_string(output, &mut base64_string);
|
||
base64_string
|
||
});
|
||
|
||
render.leaf_tag("image", |attributes| {
|
||
attributes.push("width", "1");
|
||
attributes.push("height", "1");
|
||
attributes.push("preserveAspectRatio", "none");
|
||
attributes.push("href", base64_string);
|
||
let matrix = format_transform_matrix(transform);
|
||
if !matrix.is_empty() {
|
||
attributes.push(ATTR_TRANSFORM, matrix);
|
||
}
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
if opacity < 1. {
|
||
attributes.push("opacity", opacity.to_string());
|
||
}
|
||
if blend_mode_attr != BlendMode::default() {
|
||
attributes.push("style", blend_mode_attr.render());
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _: &mut RenderContext, render_params: &RenderParams) {
|
||
for index in 0..self.len() {
|
||
let Some(image) = self.element(index) else { continue };
|
||
if image.data.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let blend_mode = blend_mode_attr.to_peniko();
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
let mut layer = false;
|
||
|
||
if (opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()))
|
||
&& let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, false)
|
||
{
|
||
let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver);
|
||
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
|
||
scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect);
|
||
layer = true;
|
||
}
|
||
|
||
let transform_attribute: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
|
||
if let RenderMode::Outline = render_params.render_mode {
|
||
let outline_transform: DAffine2 = transform * transform_attribute;
|
||
draw_raster_outline(scene, &outline_transform, render_params);
|
||
|
||
if layer {
|
||
scene.pop_layer();
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64));
|
||
|
||
let image_brush = peniko::ImageBrush::new(peniko::ImageData {
|
||
data: image.to_flat_u8().0.into(),
|
||
format: peniko::ImageFormat::Rgba8,
|
||
width: image.width,
|
||
height: image.height,
|
||
alpha_type: peniko::ImageAlphaType::Alpha,
|
||
})
|
||
.with_extend(peniko::Extend::Repeat);
|
||
|
||
scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array()));
|
||
|
||
if layer {
|
||
scene.pop_layer();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
|
||
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<Raster<...>>`
|
||
if !self.is_empty() {
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
|
||
metadata.local_transforms.insert(element_id, transform);
|
||
|
||
// If this raster carries a snapshot of upstream graphic content (e.g. it was produced by Rasterize,
|
||
// which destructively merges its inputs into pixels), recurse into that snapshot so the editor can
|
||
// surface the original child layers' click targets (the same mechanism Boolean Operation uses).
|
||
// The snapshot was captured before Rasterize shifted its input transforms to align with the rasterization
|
||
// area, so the children are already in the coordinate space matching `footprint` here — we must NOT
|
||
// multiply in `transform` (which is the rasterization area, not a layer-stack transform).
|
||
let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, 0);
|
||
if !upstream_nested_layers.is_empty() {
|
||
upstream_nested_layers.collect_metadata(metadata, footprint, None);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
||
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
|
||
}
|
||
}
|
||
|
||
static LAZY_ARC_VEC_ZERO_U8: LazyLock<Arc<Vec<u8>>> = LazyLock::new(|| Arc::new(Vec::new()));
|
||
|
||
impl Render for List<Raster<GPU>> {
|
||
fn render_svg(&self, _render: &mut SvgRender, _render_params: &RenderParams) {
|
||
log::warn!("tried to render texture as an svg");
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
|
||
for index in 0..self.len() {
|
||
let Some(raster) = self.element(index) else { continue };
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let clip_attr: bool = self.attribute_cloned_or_default(ATTR_CLIPPING_MASK, index);
|
||
let blend_mode = match render_params.render_mode {
|
||
RenderMode::Outline => peniko::Mix::Normal,
|
||
_ => blend_mode_attr.to_peniko(),
|
||
};
|
||
|
||
let mut layer = false;
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
let any_nondefault = blend_mode_attr != BlendMode::default() || opacity < 1. || clip_attr;
|
||
if (render_params.render_mode != RenderMode::Outline && any_nondefault)
|
||
&& let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, true)
|
||
{
|
||
let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver);
|
||
let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
|
||
scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect);
|
||
layer = true;
|
||
}
|
||
|
||
let transform_attribute: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
|
||
if let RenderMode::Outline = render_params.render_mode {
|
||
let outline_transform = transform * transform_attribute;
|
||
draw_raster_outline(scene, &outline_transform, render_params);
|
||
|
||
if layer {
|
||
scene.pop_layer();
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
let width = raster.data().width();
|
||
let height = raster.data().height();
|
||
let image = peniko::ImageBrush::new(peniko::ImageData {
|
||
data: peniko::Blob::new(LAZY_ARC_VEC_ZERO_U8.deref().clone()),
|
||
format: peniko::ImageFormat::Rgba8,
|
||
width,
|
||
height,
|
||
alpha_type: peniko::ImageAlphaType::Alpha,
|
||
})
|
||
.with_extend(peniko::Extend::Repeat);
|
||
let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64));
|
||
scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array()));
|
||
context.resource_overrides.push((image, raster.texture.clone()));
|
||
|
||
if layer {
|
||
scene.pop_layer()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
|
||
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<Raster<...>>`
|
||
if !self.is_empty() {
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
|
||
metadata.local_transforms.insert(element_id, transform);
|
||
|
||
// If this raster carries a snapshot of upstream graphic content (e.g. it was produced by Rasterize,
|
||
// which destructively merges its inputs into pixels), recurse into that snapshot so the editor can
|
||
// surface the original child layers' click targets (the same mechanism Boolean Operation uses).
|
||
// The snapshot was captured before Rasterize shifted its input transforms to align with the rasterization
|
||
// area, so the children are already in the coordinate space matching `footprint` here — we must NOT
|
||
// multiply in `transform` (which is the rasterization area, not a layer-stack transform).
|
||
let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, 0);
|
||
if !upstream_nested_layers.is_empty() {
|
||
upstream_nested_layers.collect_metadata(metadata, footprint, None);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
||
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
|
||
}
|
||
}
|
||
|
||
// Since colors and gradients are technically infinitely big, we have to implement
|
||
// workarounds for rendering them correctly in a way which still allows us
|
||
// to cache the intermediate render data (SVG string/Vello scene).
|
||
// For SVG, this is is achived by creating a truly giant rectangle.
|
||
// For Vello, we create a layer with a placeholder transform which we
|
||
// later replace with the current viewport transform before each render.
|
||
impl Render for List<Color> {
|
||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||
for (index, color) in self.iter_element_values().enumerate() {
|
||
let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
render.leaf_tag("polyline", |attributes| {
|
||
// Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses
|
||
// precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning.
|
||
// 1e7 stays under that limit while still being far larger than any practical document extent.
|
||
const MAX: f64 = 1e7;
|
||
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
|
||
|
||
attributes.push("fill", format!("#{}", SRGBA8::from(*color).to_rgb_hex()));
|
||
if color.a() < 1. {
|
||
attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string());
|
||
}
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
if opacity < 1. {
|
||
attributes.push("opacity", opacity.to_string());
|
||
}
|
||
|
||
if blend_mode != BlendMode::default() {
|
||
attributes.push("style", blend_mode.render());
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, _parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
|
||
use vello::peniko;
|
||
|
||
for (index, color) in self.iter_element_values().enumerate() {
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let blend_mode = blend_mode_attr.to_peniko();
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
|
||
let vello_color = SRGBA8::from(*color).to_peniko_color();
|
||
|
||
let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.));
|
||
|
||
let mut layer = false;
|
||
if opacity < 1. || blend_mode_attr != BlendMode::default() {
|
||
let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver);
|
||
scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect);
|
||
layer = true;
|
||
}
|
||
|
||
scene.fill(peniko::Fill::NonZero, kurbo::Affine::scale(f64::INFINITY), vello_color, None, &rect);
|
||
|
||
if layer {
|
||
scene.pop_layer();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Render for List<Gradient> {
|
||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||
// For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`.
|
||
// The viewBox crops the overshoot. Canvas rendering keeps the polyline path since Chrome rejects rects larger than ~20 million.
|
||
let thumbnail_rect = if render_params.thumbnail {
|
||
let truncated_size = render_params.footprint.resolution.as_dvec2();
|
||
let margin = DVec2::ONE;
|
||
Some((render_params.footprint.transform.translation - margin / 2., truncated_size + margin))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
for index in 0..self.len() {
|
||
let Some(gradient) = self.element(index) else { continue };
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let gradient_spread: GradientSpread = self.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, index);
|
||
let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, index);
|
||
let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" };
|
||
render.leaf_tag(tag, |attributes| {
|
||
if let Some((min, size)) = thumbnail_rect {
|
||
attributes.push("x", min.x.to_string());
|
||
attributes.push("y", min.y.to_string());
|
||
attributes.push("width", size.x.to_string());
|
||
attributes.push("height", size.y.to_string());
|
||
} else {
|
||
// Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses
|
||
// precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning.
|
||
// 1e7 stays under that limit while still being far larger than any practical document extent.
|
||
const MAX: f64 = 1e7;
|
||
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
|
||
}
|
||
|
||
let mut stop_string = String::new();
|
||
for (position, color, original_midpoint) in gradient.interpolated_samples() {
|
||
let _ = write!(stop_string, r##"<stop offset="{}" stop-color="#{}""##, position, SRGBA8::from(color).to_rgb_hex());
|
||
if color.a() < 1. {
|
||
let _ = write!(stop_string, r#" stop-opacity="{}""#, color.a());
|
||
}
|
||
if let Some(midpoint) = original_midpoint {
|
||
let _ = write!(stop_string, r#" graphite:midpoint="{}""#, (midpoint * 1000.).round() / 1000.);
|
||
}
|
||
stop_string.push_str(" />");
|
||
}
|
||
|
||
// render_thumbnail already added the footprint transform
|
||
let gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform };
|
||
let gradient_transform_matrix = format_transform_matrix(gradient_transform);
|
||
let gradient_transform_attribute = if gradient_transform_matrix.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(r#" gradientTransform="{gradient_transform_matrix}""#)
|
||
};
|
||
|
||
let gradient_id = generate_uuid();
|
||
let gradient_spread_attribute = if gradient_spread == GradientSpread::Pad {
|
||
String::new()
|
||
} else {
|
||
format!(r#" spreadMethod="{}""#, gradient_spread.svg_name())
|
||
};
|
||
|
||
// The unit gradient line is the +X unit vector in local space, before the item's transform is applied
|
||
match gradient_type {
|
||
GradientType::Linear => {
|
||
let _ = write!(
|
||
&mut attributes.0.svg_defs,
|
||
r#"<linearGradient id="{gradient_id}" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1" y2="0"{gradient_spread_attribute}{gradient_transform_attribute}>{stop_string}</linearGradient>"#
|
||
);
|
||
}
|
||
GradientType::Radial => {
|
||
let _ = write!(
|
||
&mut attributes.0.svg_defs,
|
||
r#"<radialGradient id="{gradient_id}" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1"{gradient_spread_attribute}{gradient_transform_attribute}>{stop_string}</radialGradient>"#
|
||
);
|
||
}
|
||
}
|
||
|
||
attributes.push("fill", format!("url('#{gradient_id}')"));
|
||
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
if opacity < 1. {
|
||
attributes.push("opacity", opacity.to_string());
|
||
}
|
||
|
||
if blend_mode != BlendMode::default() {
|
||
attributes.push("style", blend_mode.render());
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
|
||
use vello::peniko;
|
||
|
||
if let RenderMode::Outline = render_params.render_mode {
|
||
return;
|
||
}
|
||
|
||
for (((index, gradient), gradient_spread), gradient_type) in self
|
||
.iter_element_values()
|
||
.enumerate()
|
||
.zip(self.iter_attribute_values_or_default::<GradientSpread>(ATTR_GRADIENT_SPREAD))
|
||
.zip(self.iter_attribute_values_or_default::<GradientType>(ATTR_GRADIENT_TYPE))
|
||
{
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let gradient_transform = parent_transform * transform;
|
||
|
||
let blend_mode = blend_mode_attr.to_peniko();
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
|
||
let stops = peniko_color_stops(gradient);
|
||
|
||
let extend = match gradient_spread {
|
||
GradientSpread::Pad => peniko::Extend::Pad,
|
||
GradientSpread::Reflect => peniko::Extend::Reflect,
|
||
GradientSpread::Repeat => peniko::Extend::Repeat,
|
||
};
|
||
|
||
// The unit gradient line is the +X unit vector in local space, before the item's transform is applied.
|
||
// For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies.
|
||
let kind = match gradient_type {
|
||
GradientType::Linear => peniko::LinearGradientPosition {
|
||
start: to_point(DVec2::ZERO),
|
||
end: to_point(DVec2::X),
|
||
}
|
||
.into(),
|
||
GradientType::Radial => peniko::RadialGradientPosition {
|
||
start_center: to_point(DVec2::ZERO),
|
||
start_radius: 0.,
|
||
end_center: to_point(DVec2::ZERO),
|
||
end_radius: 1.,
|
||
}
|
||
.into(),
|
||
};
|
||
|
||
let fill = peniko::Brush::Gradient(peniko::Gradient {
|
||
kind,
|
||
stops,
|
||
extend,
|
||
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied,
|
||
..Default::default()
|
||
});
|
||
let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_type).to_cols_array());
|
||
let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.));
|
||
|
||
let mut layer = false;
|
||
if opacity < 1. || blend_mode_attr != BlendMode::default() {
|
||
let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver);
|
||
// See implementation in `List<Color>` 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();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`).
|
||
struct GlyphOutlinePen<'a> {
|
||
path: &'a mut BezPath,
|
||
ox: f64,
|
||
oy: f64,
|
||
tilt_tan: f64,
|
||
}
|
||
|
||
impl GlyphOutlinePen<'_> {
|
||
#[inline]
|
||
fn px(&self, x: f32, y: f32) -> f64 {
|
||
self.ox + x as f64 + (y as f64 * self.tilt_tan)
|
||
}
|
||
|
||
#[inline]
|
||
fn py(&self, y: f32) -> f64 {
|
||
self.oy - y as f64
|
||
}
|
||
}
|
||
|
||
impl OutlinePen for GlyphOutlinePen<'_> {
|
||
fn move_to(&mut self, x: f32, y: f32) {
|
||
self.path.move_to((self.px(x, y), self.py(y)));
|
||
}
|
||
fn line_to(&mut self, x: f32, y: f32) {
|
||
self.path.line_to((self.px(x, y), self.py(y)));
|
||
}
|
||
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
|
||
self.path.quad_to((self.px(cx, cy), self.py(cy)), (self.px(x, y), self.py(y)));
|
||
}
|
||
fn curve_to(&mut self, cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32) {
|
||
self.path.curve_to((self.px(cx1, cy1), self.py(cy1)), (self.px(cx2, cy2), self.py(cy2)), (self.px(x, y), self.py(y)));
|
||
}
|
||
fn close(&mut self) {
|
||
self.path.close_path();
|
||
}
|
||
}
|
||
|
||
/// Draws each glyph of `glyph_run` into a `BezPath` (with the run's position and faux-italic `tilt_tan` baked in)
|
||
/// and calls `emit` for each non-empty glyph. Zero-geometry glyphs advance by `space_extra` for justified spacing.
|
||
fn draw_glyph_run_to_bezpaths(glyph_run: &parley::GlyphRun<'_, ()>, x_offset: f32, space_extra: f32, tilt_tan: f64, mut emit: impl FnMut(&BezPath)) {
|
||
let mut run_x = glyph_run.offset() + x_offset;
|
||
let run_y = glyph_run.baseline();
|
||
let run = glyph_run.run();
|
||
let font = run.font();
|
||
let font_size_pts = run.font_size();
|
||
let normalized_coords: Vec<NormalizedCoord> = run.normalized_coords().iter().map(|c| NormalizedCoord::from_bits(*c)).collect();
|
||
|
||
let Ok(font_ref) = SkrifaFontRef::from_index(font.data.as_ref(), font.index) else { return };
|
||
let outlines = font_ref.outline_glyphs();
|
||
|
||
let mut bez_path = BezPath::new();
|
||
for glyph in glyph_run.glyphs() {
|
||
let ox = (run_x + glyph.x) as f64;
|
||
let oy = (run_y - glyph.y) as f64;
|
||
run_x += glyph.advance;
|
||
|
||
let Some(outline) = outlines.get(GlyphId::from(glyph.id)) else { continue };
|
||
let settings = DrawSettings::unhinted(Size::new(font_size_pts), LocationRef::new(&normalized_coords));
|
||
|
||
bez_path.truncate(0);
|
||
let path = &mut bez_path;
|
||
let mut pen = GlyphOutlinePen { path, ox, oy, tilt_tan };
|
||
if outline.draw(settings, &mut pen).is_ok() && !bez_path.elements().is_empty() {
|
||
emit(&bez_path);
|
||
} else if space_extra != 0. && glyph.advance > 0. {
|
||
run_x += space_extra;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Lays out text item `index` of a styled `List<String>` and returns its local size and transform. The `BoundingBox` trait can't do
|
||
/// this since a bare `String` carries no typography, so click-target and bounding-box computation share this. Falls back to an em
|
||
/// square if the font isn't registered yet.
|
||
fn text_item_size_and_transform(list: &List<String>, index: usize) -> Option<(DVec2, DAffine2)> {
|
||
let text = list.element(index)?;
|
||
let font: Resource = {
|
||
let f: Resource = list.attribute_cloned_or_default(ATTR_FONT, index);
|
||
if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f }
|
||
};
|
||
let font_size: f64 = list.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE);
|
||
let line_height: f64 = list.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2);
|
||
let letter_spacing: f64 = list.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.);
|
||
let max_width: Option<f64> = list.attribute_cloned_or(ATTR_MAX_WIDTH, index, None);
|
||
let max_height: Option<f64> = list.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None);
|
||
let align: text_nodes::TextAlign = list.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index);
|
||
let transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
|
||
let typesetting = text_nodes::TypesettingConfig {
|
||
font_size,
|
||
line_height_ratio: line_height,
|
||
letter_spacing,
|
||
letter_tilt: 0.,
|
||
max_width,
|
||
max_height,
|
||
align,
|
||
};
|
||
|
||
let (width, height) = text_nodes::TextContext::with_thread_local(|ctx| {
|
||
ctx.layout_text(text, &font, typesetting).map(|layout| {
|
||
let w = max_width.unwrap_or_else(|| layout.width() as f64);
|
||
let h = max_height.unwrap_or_else(|| layout.height() as f64);
|
||
(w, h)
|
||
})
|
||
})
|
||
.unwrap_or((font_size, font_size));
|
||
|
||
Some((DVec2::new(width, height), transform))
|
||
}
|
||
|
||
/// Union bounding box of a styled `List<String>`, laid out per item. The `BoundingBox` trait returns `None` for `List<String>`
|
||
/// (a bare `String` has no extent), so text-layer thumbnails and bounds use this instead. Each item is laid out under `outer_transform`.
|
||
pub fn text_list_bounding_box(list: &List<String>, outer_transform: DAffine2) -> RenderBoundingBox {
|
||
let mut bounds: Option<[DVec2; 2]> = None;
|
||
for index in 0..list.len() {
|
||
let Some((size, transform)) = text_item_size_and_transform(list, index) else { continue };
|
||
let full_transform = outer_transform * transform;
|
||
for corner in [DVec2::ZERO, DVec2::new(size.x, 0.), DVec2::new(0., size.y), size] {
|
||
let point = full_transform.transform_point2(corner);
|
||
bounds = Some(match bounds {
|
||
Some([min, max]) => [min.min(point), max.max(point)],
|
||
None => [point, point],
|
||
});
|
||
}
|
||
}
|
||
match bounds {
|
||
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
|
||
None => RenderBoundingBox::None,
|
||
}
|
||
}
|
||
|
||
/// Like `List<Graphic>::thumbnail_bounding_box`, but lays out `Graphic::Text` items, which the `BoundingBox` trait reports as `None`.
|
||
/// Used for layer thumbnails so text layers (whose content is a `List<Graphic>` wrapping the text) frame their content.
|
||
pub fn graphic_list_bounding_box(list: &List<Graphic>, transform: DAffine2) -> RenderBoundingBox {
|
||
let mut combined: Option<[DVec2; 2]> = None;
|
||
let mut any_infinite = false;
|
||
|
||
for index in 0..list.len() {
|
||
let item_transform = transform * list.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, index);
|
||
let Some(graphic) = list.element(index) else { continue };
|
||
let bounds = match graphic {
|
||
Graphic::Text(text_list) => text_list_bounding_box(text_list, item_transform),
|
||
Graphic::Graphic(sub_list) => graphic_list_bounding_box(sub_list, item_transform),
|
||
other => other.thumbnail_bounding_box(item_transform, true),
|
||
};
|
||
match bounds {
|
||
RenderBoundingBox::None => {}
|
||
RenderBoundingBox::Infinite => any_infinite = true,
|
||
RenderBoundingBox::Rectangle([min, max]) => {
|
||
combined = Some(match combined {
|
||
Some([existing_min, existing_max]) => [existing_min.min(min), existing_max.max(max)],
|
||
None => [min, max],
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
match (combined, any_infinite) {
|
||
(Some(bounds), _) => RenderBoundingBox::Rectangle(bounds),
|
||
(None, true) => RenderBoundingBox::Infinite,
|
||
(None, false) => RenderBoundingBox::None,
|
||
}
|
||
}
|
||
|
||
impl Render for List<String> {
|
||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||
for index in 0..self.len() {
|
||
let Some(text) = self.element(index) else { continue };
|
||
if text.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let font: Resource = {
|
||
let f: Resource = self.attribute_cloned_or_default(ATTR_FONT, index);
|
||
if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f }
|
||
};
|
||
let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE);
|
||
let line_height: f64 = self.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2);
|
||
let letter_spacing: f64 = self.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.);
|
||
let max_width: Option<f64> = self.attribute_cloned_or(ATTR_MAX_WIDTH, index, None);
|
||
let max_height: Option<f64> = self.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None);
|
||
let letter_tilt: f64 = self.attribute_cloned_or(ATTR_LETTER_TILT, index, 0.);
|
||
let align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index);
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
|
||
let typesetting = text_nodes::TypesettingConfig {
|
||
font_size,
|
||
line_height_ratio: line_height,
|
||
letter_spacing,
|
||
letter_tilt,
|
||
max_width,
|
||
max_height,
|
||
align,
|
||
};
|
||
|
||
let mut glyph_paths: Vec<String> = Vec::new();
|
||
|
||
text_nodes::TextContext::with_thread_local(|ctx| {
|
||
let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return };
|
||
let tilt_tan = letter_tilt.to_radians().tan();
|
||
|
||
text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| {
|
||
draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| {
|
||
glyph_paths.push(bez_path.to_svg());
|
||
});
|
||
});
|
||
});
|
||
|
||
if glyph_paths.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
// Wrap all glyph <path> elements in a <g> with the item's transform/opacity/blend-mode.
|
||
render.parent_tag(
|
||
"g",
|
||
|attributes| {
|
||
let matrix = format_transform_matrix(transform);
|
||
if !matrix.is_empty() {
|
||
attributes.push("transform", matrix);
|
||
}
|
||
if opacity < 1. {
|
||
attributes.push("opacity", opacity.to_string());
|
||
}
|
||
if blend_mode_attr != BlendMode::default() {
|
||
attributes.push("style", blend_mode_attr.render());
|
||
}
|
||
},
|
||
|render| {
|
||
for path_d in glyph_paths {
|
||
render.leaf_tag("path", |attributes| {
|
||
attributes.push("d", path_d);
|
||
if let RenderMode::Outline = render_params.render_mode {
|
||
attributes.push("fill", "none");
|
||
attributes.push("stroke", "black");
|
||
attributes.push("stroke-width", "1");
|
||
} else {
|
||
attributes.push("fill", "black");
|
||
attributes.push("fill-rule", "nonzero");
|
||
}
|
||
});
|
||
}
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
|
||
for index in 0..self.len() {
|
||
let Some(text) = self.element(index) else { continue };
|
||
if text.is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
let font: Resource = {
|
||
let f: Resource = self.attribute_cloned_or_default(ATTR_FONT, index);
|
||
if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f }
|
||
};
|
||
let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE);
|
||
let line_height: f64 = self.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2);
|
||
let letter_spacing: f64 = self.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.);
|
||
let max_width: Option<f64> = self.attribute_cloned_or(ATTR_MAX_WIDTH, index, None);
|
||
let max_height: Option<f64> = self.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None);
|
||
let letter_tilt: f64 = self.attribute_cloned_or(ATTR_LETTER_TILT, index, 0.);
|
||
let align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index);
|
||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||
|
||
let typesetting = text_nodes::TypesettingConfig {
|
||
font_size,
|
||
line_height_ratio: line_height,
|
||
letter_spacing,
|
||
letter_tilt,
|
||
max_width,
|
||
max_height,
|
||
align,
|
||
};
|
||
|
||
let affine = Affine::new((transform * item_transform).to_cols_array());
|
||
|
||
text_nodes::TextContext::with_thread_local(|ctx| {
|
||
let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return };
|
||
|
||
let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default();
|
||
if needs_layer {
|
||
let alignment_width = max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width());
|
||
let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver);
|
||
let padding = font_size;
|
||
let bounds = kurbo::Rect::new(-padding, -padding, alignment_width as f64 + padding, layout.height() as f64 + padding);
|
||
let transformed_bounds = affine.transform_rect_bbox(bounds);
|
||
scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds);
|
||
}
|
||
|
||
let tilt_tan = letter_tilt.to_radians().tan();
|
||
|
||
text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| {
|
||
draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| {
|
||
if let RenderMode::Outline = render_params.render_mode {
|
||
let (outline_stroke, outline_color) = get_outline_styles(render_params);
|
||
scene.stroke(&outline_stroke, affine, outline_color, None, bez_path);
|
||
} else {
|
||
scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, bez_path);
|
||
}
|
||
});
|
||
});
|
||
|
||
if needs_layer {
|
||
scene.pop_layer();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
|
||
// Click targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]`.
|
||
let item_zero_transform: DAffine2 = if !self.is_empty() {
|
||
self.attribute_cloned_or_default(ATTR_TRANSFORM, 0)
|
||
} else {
|
||
DAffine2::IDENTITY
|
||
};
|
||
let item_zero_inverse = if item_zero_transform.matrix2.determinant() != 0. {
|
||
item_zero_transform.inverse()
|
||
} else {
|
||
DAffine2::IDENTITY
|
||
};
|
||
|
||
let mut accumulated_click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
|
||
|
||
for index in 0..self.len() {
|
||
let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0;
|
||
let layer = layer_path.iter_element_values().next_back().copied();
|
||
let Some(element_id) = caller_element_id.or(layer) else { continue };
|
||
|
||
// When recovering element_id from the item's tag (caller passed None), also store the transform metadata.
|
||
if caller_element_id.is_none() {
|
||
metadata.upstream_footprints.entry(element_id).or_insert(footprint);
|
||
metadata.local_transforms.entry(element_id).or_insert(item_zero_transform);
|
||
}
|
||
|
||
let Some((size, item_transform)) = text_item_size_and_transform(self, index) else { continue };
|
||
let subpath = Subpath::new_rectangle(DVec2::ZERO, size);
|
||
let mut target = ClickTarget::new_with_subpath(subpath, 0.);
|
||
target.apply_transform(item_zero_inverse * item_transform);
|
||
accumulated_click_targets.entry(element_id).or_default().push(Arc::new(target));
|
||
}
|
||
|
||
// One rectangle per text item, reused for the selection outline (there's no letterform geometry to outline at this stage).
|
||
for (element_id, targets) in accumulated_click_targets {
|
||
metadata.outlines.insert(element_id, targets.clone());
|
||
metadata.click_targets.insert(element_id, targets);
|
||
}
|
||
}
|
||
|
||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||
for index in 0..self.len() {
|
||
let Some((size, transform)) = text_item_size_and_transform(self, index) else { continue };
|
||
let subpath = Subpath::new_rectangle(DVec2::ZERO, size);
|
||
let mut target = ClickTarget::new_with_subpath(subpath, 0.);
|
||
target.apply_transform(transform);
|
||
click_targets.push(target);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum SvgSegment {
|
||
Slice(&'static str),
|
||
String(String),
|
||
}
|
||
|
||
impl From<String> for SvgSegment {
|
||
fn from(value: String) -> Self {
|
||
Self::String(value)
|
||
}
|
||
}
|
||
|
||
impl From<&'static str> for SvgSegment {
|
||
fn from(value: &'static str) -> Self {
|
||
Self::Slice(value)
|
||
}
|
||
}
|
||
|
||
pub trait RenderSvgSegmentList {
|
||
fn to_svg_string(&self) -> String;
|
||
}
|
||
|
||
impl RenderSvgSegmentList for Vec<SvgSegment> {
|
||
fn to_svg_string(&self) -> String {
|
||
let mut result = String::new();
|
||
for segment in self.iter() {
|
||
result.push_str(match segment {
|
||
SvgSegment::Slice(x) => x,
|
||
SvgSegment::String(x) => x,
|
||
});
|
||
}
|
||
result
|
||
}
|
||
}
|
||
|
||
pub struct SvgRenderAttrs<'a>(&'a mut SvgRender);
|
||
|
||
impl SvgRenderAttrs<'_> {
|
||
pub fn push_complex(&mut self, name: impl Into<SvgSegment>, value: impl FnOnce(&mut SvgRender)) {
|
||
self.0.svg.push(" ".into());
|
||
self.0.svg.push(name.into());
|
||
self.0.svg.push("=\"".into());
|
||
value(self.0);
|
||
self.0.svg.push("\"".into());
|
||
}
|
||
pub fn push(&mut self, name: impl Into<SvgSegment>, value: impl Into<SvgSegment>) {
|
||
self.push_complex(name, move |renderer| renderer.svg.push(value.into()));
|
||
}
|
||
pub fn push_val(&mut self, value: impl Into<SvgSegment>) {
|
||
self.0.svg.push(value.into());
|
||
}
|
||
}
|