mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-03 21:08:18 +00:00
Improve rendered SVG output syntax for better compatibility and terseness (#1880)
* Improve rendered SVG output syntax for better compatibility and terseness * Fix CI failing on boolean operations without wasm32? * Attempt 2
This commit is contained in:
parent
22ebe9a2cb
commit
44ffb635e9
12 changed files with 249 additions and 189 deletions
|
@ -1,4 +1,5 @@
|
|||
mod quad;
|
||||
pub use quad::Quad;
|
||||
|
||||
use crate::raster::bbox::Bbox;
|
||||
use crate::raster::{BlendMode, Image, ImageFrame};
|
||||
|
@ -8,12 +9,13 @@ use crate::vector::style::{Fill, Stroke, ViewMode};
|
|||
use crate::vector::PointId;
|
||||
use crate::SurfaceFrame;
|
||||
use crate::{vector::VectorData, Artboard, Color, GraphicElement, GraphicGroup};
|
||||
pub use quad::Quad;
|
||||
|
||||
use bezier_rs::Subpath;
|
||||
|
||||
use base64::Engine;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use num_traits::Zero;
|
||||
use std::fmt::Write;
|
||||
#[cfg(feature = "vello")]
|
||||
use vello::*;
|
||||
|
||||
|
@ -107,11 +109,10 @@ impl SvgRender {
|
|||
.map(|size| format!("viewbox=\"0 0 {} {}\" width=\"{}\" height=\"{}\"", size.x, size.y, size.x, size.y))
|
||||
.unwrap_or_default();
|
||||
|
||||
let svg_header = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" {}><defs>{defs}</defs><g transform="{}">"#,
|
||||
view_box,
|
||||
format_transform_matrix(transform)
|
||||
);
|
||||
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" {}><defs>{defs}</defs><g{transform}>"#, view_box);
|
||||
self.svg.insert(0, svg_header.into());
|
||||
self.svg.push("</g></svg>".into());
|
||||
}
|
||||
|
@ -193,17 +194,16 @@ impl RenderParams {
|
|||
}
|
||||
|
||||
pub fn format_transform_matrix(transform: DAffine2) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut result = "matrix(".to_string();
|
||||
let cols = transform.to_cols_array();
|
||||
for (index, item) in cols.iter().enumerate() {
|
||||
write!(result, "{item}").unwrap();
|
||||
if index != cols.len() - 1 {
|
||||
result.push_str(", ");
|
||||
}
|
||||
if transform == DAffine2::IDENTITY {
|
||||
return String::new();
|
||||
}
|
||||
result.push(')');
|
||||
result
|
||||
|
||||
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)
|
||||
}) + ")"
|
||||
}
|
||||
|
||||
pub fn to_transform(transform: DAffine2) -> usvg::Transform {
|
||||
|
@ -234,7 +234,10 @@ impl GraphicElementRendered for GraphicGroup {
|
|||
render.parent_tag(
|
||||
"g",
|
||||
|attributes| {
|
||||
attributes.push("transform", format_transform_matrix(self.transform));
|
||||
let matrix = format_transform_matrix(self.transform);
|
||||
if !matrix.is_empty() {
|
||||
attributes.push("transform", matrix);
|
||||
}
|
||||
|
||||
if self.alpha_blending.opacity < 1. {
|
||||
attributes.push("opacity", self.alpha_blending.opacity.to_string());
|
||||
|
@ -305,8 +308,6 @@ impl GraphicElementRendered for VectorData {
|
|||
}
|
||||
|
||||
render.leaf_tag("path", |attributes| {
|
||||
attributes.push("class", "vector-data");
|
||||
|
||||
attributes.push("d", path);
|
||||
|
||||
let fill_and_stroke = self
|
||||
|
@ -443,8 +444,10 @@ impl GraphicElementRendered for Artboard {
|
|||
if !render_params.hide_artboards {
|
||||
// Background
|
||||
render.leaf_tag("rect", |attributes| {
|
||||
attributes.push("class", "artboard-bg");
|
||||
attributes.push("fill", format!("#{}", self.background.rgba_hex()));
|
||||
attributes.push("fill", format!("#{}", self.background.rgb_hex()));
|
||||
if self.background.a() < 1. {
|
||||
attributes.push("fill-opacity", ((self.background.a() * 1000.).round() / 1000.).to_string());
|
||||
}
|
||||
attributes.push("x", self.location.x.min(self.location.x + self.dimensions.x).to_string());
|
||||
attributes.push("y", self.location.y.min(self.location.y + self.dimensions.y).to_string());
|
||||
attributes.push("width", self.dimensions.x.abs().to_string());
|
||||
|
@ -456,7 +459,6 @@ impl GraphicElementRendered for Artboard {
|
|||
render.parent_tag(
|
||||
"text",
|
||||
|attributes| {
|
||||
attributes.push("class", "artboard-label");
|
||||
attributes.push("fill", "white");
|
||||
attributes.push("x", (self.location.x.min(self.location.x + self.dimensions.x)).to_string());
|
||||
attributes.push("y", (self.location.y.min(self.location.y + self.dimensions.y) - 4).to_string());
|
||||
|
@ -475,23 +477,22 @@ impl GraphicElementRendered for Artboard {
|
|||
"g",
|
||||
// Group tag attributes
|
||||
|attributes| {
|
||||
attributes.push("class", "artboard");
|
||||
|
||||
attributes.push(
|
||||
"transform",
|
||||
format_transform_matrix(DAffine2::from_translation(self.location.as_dvec2()) * self.graphic_group.transform),
|
||||
);
|
||||
let matrix = format_transform_matrix(DAffine2::from_translation(self.location.as_dvec2()) * self.graphic_group.transform);
|
||||
if !matrix.is_empty() {
|
||||
attributes.push("transform", matrix);
|
||||
}
|
||||
|
||||
if self.clip {
|
||||
let id = format!("artboard-{}", generate_uuid());
|
||||
let selector = format!("url(#{id})");
|
||||
use std::fmt::Write;
|
||||
|
||||
let matrix = format_transform_matrix(self.graphic_group.transform.inverse());
|
||||
let transform = if matrix.is_empty() { String::new() } else { format!(r#" transform="{matrix}""#) };
|
||||
|
||||
write!(
|
||||
&mut attributes.0.svg_defs,
|
||||
r##"<clipPath id="{id}"><rect x="0" y="0" width="{}" height="{}" transform="{}"/></clipPath>"##,
|
||||
self.dimensions.x,
|
||||
self.dimensions.y,
|
||||
format_transform_matrix(self.graphic_group.transform.inverse())
|
||||
r##"<clipPath id="{id}"><rect x="0" y="0" width="{}" height="{}"{transform} /></clipPath>"##,
|
||||
self.dimensions.x, self.dimensions.y
|
||||
)
|
||||
.unwrap();
|
||||
attributes.push("clip-path", selector);
|
||||
|
@ -580,18 +581,16 @@ impl GraphicElementRendered for crate::ArtboardGroup {
|
|||
impl GraphicElementRendered for SurfaceFrame {
|
||||
fn render_svg(&self, render: &mut SvgRender, _render_params: &RenderParams) {
|
||||
let transform = self.transform;
|
||||
|
||||
let (width, height) = (transform.transform_vector2(DVec2::new(1., 0.)).length(), transform.transform_vector2(DVec2::new(0., 1.)).length());
|
||||
let matrix = (transform * DAffine2::from_scale((width, height).into()).inverse())
|
||||
.to_cols_array()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(String::new(), |val, (i, entry)| val + &(entry.to_string() + if i == 5 { "" } else { "," }));
|
||||
|
||||
let matrix = format_transform_matrix(transform * DAffine2::from_scale((width, height).into()).inverse());
|
||||
let transform = if matrix.is_empty() { String::new() } else { format!(r#" transform="{}""#, matrix) };
|
||||
|
||||
let canvas = format!(
|
||||
r#"<foreignObject width="{}" height="{}" transform="matrix({})"><div data-canvas-placeholder="canvas{}"></div></foreignObject>"#,
|
||||
r#"<foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="canvas{}"></div></foreignObject>"#,
|
||||
width.abs(),
|
||||
height.abs(),
|
||||
matrix,
|
||||
self.surface_id
|
||||
);
|
||||
render.svg.push(canvas.into())
|
||||
|
@ -617,7 +616,7 @@ impl GraphicElementRendered for SurfaceFrame {
|
|||
|
||||
impl GraphicElementRendered for ImageFrame<Color> {
|
||||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||||
let transform: String = format_transform_matrix(self.transform * render.transform);
|
||||
let transform = self.transform * render.transform;
|
||||
|
||||
match render_params.image_render_mode {
|
||||
ImageRenderMode::Base64 => {
|
||||
|
@ -638,8 +637,11 @@ impl GraphicElementRendered for ImageFrame<Color> {
|
|||
attributes.push("width", 1.to_string());
|
||||
attributes.push("height", 1.to_string());
|
||||
attributes.push("preserveAspectRatio", "none");
|
||||
attributes.push("transform", transform);
|
||||
attributes.push("href", base64_string);
|
||||
let matrix = format_transform_matrix(transform);
|
||||
if !matrix.is_empty() {
|
||||
attributes.push("transform", matrix);
|
||||
}
|
||||
if self.alpha_blending.blend_mode != BlendMode::default() {
|
||||
attributes.push("style", self.alpha_blending.blend_mode.render());
|
||||
}
|
||||
|
@ -765,7 +767,10 @@ impl GraphicElementRendered for Option<Color> {
|
|||
attributes.push("width", "100");
|
||||
attributes.push("height", "100");
|
||||
attributes.push("y", "40");
|
||||
attributes.push("fill", format!("#{}", color.rgba_hex()));
|
||||
attributes.push("fill", format!("#{}", color.rgb_hex()));
|
||||
if color.a() < 1. {
|
||||
attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string());
|
||||
}
|
||||
});
|
||||
render.parent_tag("text", text_attributes, |render| render.leaf_node(color_info))
|
||||
}
|
||||
|
@ -785,7 +790,10 @@ impl GraphicElementRendered for Vec<Color> {
|
|||
attributes.push("height", "100");
|
||||
attributes.push("x", (index * 120).to_string());
|
||||
attributes.push("y", "40");
|
||||
attributes.push("fill", format!("#{}", color.rgba_hex()));
|
||||
attributes.push("fill", format!("#{}", color.rgb_hex()));
|
||||
if color.a() < 1. {
|
||||
attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,18 @@
|
|||
use core::hash::Hash;
|
||||
use half::f16;
|
||||
use super::discrete_srgb::{float_to_srgb_u8, srgb_u8_to_float};
|
||||
use super::{Alpha, AssociatedAlpha, Luminance, LuminanceMut, Pixel, RGBMut, Rec709Primaries, RGB, SRGB};
|
||||
|
||||
use dyn_any::{DynAny, StaticType};
|
||||
#[cfg(feature = "serde")]
|
||||
#[cfg(target_arch = "spirv")]
|
||||
use spirv_std::num_traits::float::Float;
|
||||
|
||||
#[cfg(target_arch = "spirv")]
|
||||
use spirv_std::num_traits::Euclid;
|
||||
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use core::hash::Hash;
|
||||
use half::f16;
|
||||
use std::fmt::Write;
|
||||
|
||||
use super::{
|
||||
discrete_srgb::{float_to_srgb_u8, srgb_u8_to_float},
|
||||
Alpha, AssociatedAlpha, Luminance, LuminanceMut, Pixel, RGBMut, Rec709Primaries, RGB, SRGB,
|
||||
};
|
||||
#[repr(C)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, Pod, Zeroable)]
|
||||
|
@ -813,12 +811,12 @@ impl Color {
|
|||
/// ```
|
||||
/// use graphene_core::raster::color::Color;
|
||||
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61).to_gamma_srgb();
|
||||
/// assert_eq!("3240A261", color.rgba_hex())
|
||||
/// assert_eq!("3240a261", color.rgba_hex())
|
||||
/// ```
|
||||
#[cfg(feature = "std")]
|
||||
pub fn rgba_hex(&self) -> String {
|
||||
format!(
|
||||
"{:02X?}{:02X?}{:02X?}{:02X?}",
|
||||
"{:02x?}{:02x?}{:02x?}{:02x?}",
|
||||
(self.r() * 255.) as u8,
|
||||
(self.g() * 255.) as u8,
|
||||
(self.b() * 255.) as u8,
|
||||
|
@ -826,15 +824,34 @@ impl Color {
|
|||
)
|
||||
}
|
||||
|
||||
/// Return a 6-character RGB, or 8-character RGBA, hex string (without a # prefix). The shorter form is used if the alpha is 1.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use graphene_core::raster::color::Color;
|
||||
/// let color1 = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61).to_gamma_srgb();
|
||||
/// assert_eq!("3240a261", color1.rgb_optional_a_hex())
|
||||
/// let color2 = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61).to_gamma_srgb();
|
||||
/// assert_eq!("3240a2", color2.rgb_optional_a_hex())
|
||||
/// ```
|
||||
#[cfg(feature = "std")]
|
||||
pub fn rgb_optional_a_hex(&self) -> String {
|
||||
let mut result = format!("{:02x?}{:02x?}{:02x?}", (self.r() * 255.) as u8, (self.g() * 255.) as u8, (self.b() * 255.) as u8);
|
||||
if self.a() < 1. {
|
||||
let _ = write!(&mut result, "{:02x?}", (self.a() * 255.) as u8);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Return a 6-character RGB hex string (without a # prefix).
|
||||
/// ```
|
||||
/// use graphene_core::raster::color::Color;
|
||||
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61).to_gamma_srgb();
|
||||
/// assert_eq!("3240A2", color.rgb_hex())
|
||||
/// assert_eq!("3240a2", color.rgb_hex())
|
||||
/// ```
|
||||
#[cfg(feature = "std")]
|
||||
pub fn rgb_hex(&self) -> String {
|
||||
format!("{:02X?}{:02X?}{:02X?}", (self.r() * 255.) as u8, (self.g() * 255.) as u8, (self.b() * 255.) as u8)
|
||||
format!("{:02x?}{:02x?}{:02x?}", (self.r() * 255.) as u8, (self.g() * 255.) as u8, (self.b() * 255.) as u8)
|
||||
}
|
||||
|
||||
/// Return the all components as a u8 slice, first component is red, followed by green, followed by blue, followed by alpha.
|
||||
|
|
|
@ -1,25 +1,13 @@
|
|||
//! Contains stylistic options for SVG elements.
|
||||
|
||||
use crate::consts::{LAYER_OUTLINE_STROKE_COLOR, LAYER_OUTLINE_STROKE_WEIGHT};
|
||||
use crate::renderer::format_transform_matrix;
|
||||
use crate::Color;
|
||||
|
||||
use dyn_any::{DynAny, StaticType};
|
||||
use glam::{DAffine2, DVec2};
|
||||
|
||||
use std::fmt::{self, Display, Write};
|
||||
|
||||
/// Precision of the opacity value in digits after the decimal point.
|
||||
/// A value of 3 would correspond to a precision of 10^-3.
|
||||
const OPACITY_PRECISION: usize = 3;
|
||||
|
||||
fn format_opacity(attribute: &str, opacity: f32) -> String {
|
||||
if (opacity - 1.).abs() > 10_f32.powi(-(OPACITY_PRECISION as i32)) {
|
||||
format!(r#" {attribute}="{opacity:.OPACITY_PRECISION$}""#)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, serde::Serialize, serde::Deserialize, DynAny, specta::Type)]
|
||||
pub enum GradientType {
|
||||
#[default]
|
||||
|
@ -134,7 +122,15 @@ impl Gradient {
|
|||
|
||||
let mut stop = String::new();
|
||||
for (position, color) in self.stops.0.iter() {
|
||||
let _ = write!(stop, r##"<stop offset="{}" stop-color="#{}" />"##, position, color.with_alpha(color.a()).rgba_hex());
|
||||
stop.push_str("<stop");
|
||||
if *position != 0. {
|
||||
let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.);
|
||||
}
|
||||
let _ = write!(stop, r##" stop-color="#{}""##, color.rgb_hex());
|
||||
if color.a() < 1. {
|
||||
let _ = write!(stop, r#" stop-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
|
||||
}
|
||||
stop.push_str(" />")
|
||||
}
|
||||
|
||||
let mod_gradient = transformed_bound_transform.inverse();
|
||||
|
@ -143,28 +139,25 @@ impl Gradient {
|
|||
let start = mod_points.transform_point2(self.start);
|
||||
let end = mod_points.transform_point2(self.end);
|
||||
|
||||
let transform = mod_gradient
|
||||
.to_cols_array()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| entry.to_string() + if i == 5 { "" } else { "," })
|
||||
.collect::<String>();
|
||||
|
||||
let gradient_id = crate::uuid::generate_uuid();
|
||||
|
||||
let matrix = format_transform_matrix(mod_gradient);
|
||||
let gradient_transform = if matrix.is_empty() { String::new() } else { format!(r#" gradientTransform="{}""#, matrix) };
|
||||
|
||||
match self.gradient_type {
|
||||
GradientType::Linear => {
|
||||
let _ = write!(
|
||||
svg_defs,
|
||||
r#"<linearGradient id="{}" x1="{}" x2="{}" y1="{}" y2="{}" gradientTransform="matrix({})">{}</linearGradient>"#,
|
||||
gradient_id, start.x, end.x, start.y, end.y, transform, stop
|
||||
r#"<linearGradient id="{}" x1="{}" x2="{}" y1="{}" y2="{}"{gradient_transform}>{}</linearGradient>"#,
|
||||
gradient_id, start.x, end.x, start.y, end.y, stop
|
||||
);
|
||||
}
|
||||
GradientType::Radial => {
|
||||
let radius = (f64::powi(start.x - end.x, 2) + f64::powi(start.y - end.y, 2)).sqrt();
|
||||
let _ = write!(
|
||||
svg_defs,
|
||||
r#"<radialGradient id="{}" cx="{}" cy="{}" r="{}" gradientTransform="matrix({})">{}</radialGradient>"#,
|
||||
gradient_id, start.x, start.y, radius, transform, stop
|
||||
r#"<radialGradient id="{}" cx="{}" cy="{}" r="{}"{gradient_transform}>{}</radialGradient>"#,
|
||||
gradient_id, start.x, start.y, radius, stop
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -277,7 +270,13 @@ impl Fill {
|
|||
pub fn render(&self, svg_defs: &mut String, multiplied_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String {
|
||||
match self {
|
||||
Self::None => r#" fill="none""#.to_string(),
|
||||
Self::Solid(color) => format!(r##" fill="#{}"{}"##, color.rgb_hex(), format_opacity("fill-opacity", color.a())),
|
||||
Self::Solid(color) => {
|
||||
let mut result = format!(r##" fill="#{}""##, color.rgb_hex());
|
||||
if color.a() < 1. {
|
||||
let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
|
||||
}
|
||||
result
|
||||
}
|
||||
Self::Gradient(gradient) => {
|
||||
let gradient_id = gradient.render_defs(svg_defs, multiplied_transform, bounds, transformed_bounds);
|
||||
format!(r##" fill="url('#{gradient_id}')""##)
|
||||
|
@ -505,21 +504,45 @@ impl Stroke {
|
|||
|
||||
/// Provide the SVG attributes for the stroke.
|
||||
pub fn render(&self) -> String {
|
||||
if let Some(color) = self.color {
|
||||
format!(
|
||||
r##" stroke="#{}"{} stroke-width="{}" stroke-dasharray="{}" stroke-dashoffset="{}" stroke-linecap="{}" stroke-linejoin="{}" stroke-miterlimit="{}" "##,
|
||||
color.rgb_hex(),
|
||||
format_opacity("stroke-opacity", color.a()),
|
||||
self.weight,
|
||||
self.dash_lengths(),
|
||||
self.dash_offset,
|
||||
self.line_cap,
|
||||
self.line_join,
|
||||
self.line_join_miter_limit
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
// Don't render a stroke at all if it would be invisible
|
||||
let Some(color) = self.color else { return String::new() };
|
||||
if self.weight <= 0. || color.a() == 0. {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Set to None if the value is the SVG default
|
||||
let weight = (self.weight != 1.).then_some(self.weight);
|
||||
let dash_array = (!self.dash_lengths.is_empty()).then_some(self.dash_lengths());
|
||||
let dash_offset = (self.dash_offset != 0.).then_some(self.dash_offset);
|
||||
let line_cap = (self.line_cap != LineCap::Butt).then_some(self.line_cap);
|
||||
let line_join = (self.line_join != LineJoin::Miter).then_some(self.line_join);
|
||||
let line_join_miter_limit = (self.line_join_miter_limit != 4.).then_some(self.line_join_miter_limit);
|
||||
|
||||
// Render the needed stroke attributes
|
||||
let mut attributes = format!(r##" stroke="#{}""##, color.rgb_hex());
|
||||
if color.a() < 1. {
|
||||
let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
|
||||
}
|
||||
if let Some(weight) = weight {
|
||||
let _ = write!(&mut attributes, r#" stroke-width="{}""#, weight);
|
||||
}
|
||||
if let Some(dash_array) = dash_array {
|
||||
let _ = write!(&mut attributes, r#" stroke-dasharray="{}""#, dash_array);
|
||||
}
|
||||
if let Some(dash_offset) = dash_offset {
|
||||
let _ = write!(&mut attributes, r#" stroke-dashoffset="{}""#, dash_offset);
|
||||
}
|
||||
if let Some(line_cap) = line_cap {
|
||||
let _ = write!(&mut attributes, r#" stroke-linecap="{}""#, line_cap);
|
||||
}
|
||||
if let Some(line_join) = line_join {
|
||||
let _ = write!(&mut attributes, r#" stroke-linejoin="{}""#, line_join);
|
||||
}
|
||||
if let Some(line_join_miter_limit) = line_join_miter_limit {
|
||||
let _ = write!(&mut attributes, r#" stroke-miterlimit="{}""#, line_join_miter_limit);
|
||||
}
|
||||
|
||||
attributes
|
||||
}
|
||||
|
||||
pub fn with_color(mut self, color: &Option<Color>) -> Option<Self> {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue