use super::convert::{color_to_rgba, point_attrs, rect_attrs};
use crate::compat::{format, MiniToString, String, Vec};
use crate::core::{Color, Font, Point, Size};
use crate::render::core::command::{BlendMode, RenderCommand};
use crate::render::core::types::{ShapedText, TextMetrics};
use crate::render::text::{is_combining_mark, is_variation_selector};
use crate::render::{PaintBackend, SoftwareRenderConfig};
use crate::style::gradient::GradientType;
pub struct SvgPaintBackend {
pub(crate) size: Size,
dpi_scale: f32,
pub(crate) elements: Vec<String>,
clip_depth: u32,
clip_path_counter: u32,
svg_output: Option<String>,
gradient_counter: u32,
blend_group_open: bool,
blur_group_open: bool,
}
impl SvgPaintBackend {
pub fn new(size: Size) -> Self {
Self {
size,
dpi_scale: 1.0,
elements: Vec::new(),
clip_depth: 0,
clip_path_counter: 0,
svg_output: None,
gradient_counter: 0,
blend_group_open: false,
blur_group_open: false,
}
}
fn close_blend_group(&mut self) {
if self.blend_group_open {
self.push_element("</g>".to_string());
self.blend_group_open = false;
}
}
fn close_blur_group(&mut self) {
if self.blur_group_open {
self.push_element("</g>".to_string());
self.blur_group_open = false;
}
}
pub fn finish(&mut self) -> String {
if let Some(svg) = self.svg_output.take() {
return svg;
}
self.build_svg()
}
fn build_svg(&self) -> String {
let mut svg = String::from(r#"<svg xmlns="http://www.w3.org/2000/svg""#);
svg.push_str(&format!(r#" width="{}" height="{}""#, self.size.width, self.size.height));
svg.push_str(&format!(r#" viewBox="0 0 {} {}">"#, self.size.width, self.size.height));
for element in &self.elements {
svg.push('\n');
svg.push_str(" ");
svg.push_str(element);
}
if self.blur_group_open {
svg.push_str("\n</g>");
}
if self.blend_group_open {
svg.push_str("\n</g>");
}
svg.push_str("\n</svg>");
svg
}
fn push_element(&mut self, element: String) {
self.elements.push(element);
}
}
fn blend_mode_to_css(mode: BlendMode) -> &'static str {
match mode {
BlendMode::Normal => "normal",
BlendMode::Multiply => "multiply",
BlendMode::Screen => "screen",
BlendMode::Overlay => "overlay",
BlendMode::Darken => "darken",
BlendMode::Lighten => "lighten",
BlendMode::ColorDodge => "color-dodge",
BlendMode::ColorBurn => "color-burn",
BlendMode::HardLight => "hard-light",
BlendMode::SoftLight => "soft-light",
BlendMode::Difference => "difference",
BlendMode::Exclusion => "exclusion",
BlendMode::Hue => "hue",
BlendMode::Saturation => "saturation",
BlendMode::Color => "color",
BlendMode::Luminosity => "luminosity",
}
}
fn conic_sample(stops: &[(f32, Color)], t: f32, start_angle: f32) -> Color {
let _ = start_angle;
if stops.is_empty() {
return Color::TRANSPARENT;
}
if t <= stops[0].0 {
return stops[0].1;
}
let last = stops[stops.len() - 1];
if t >= last.0 {
return last.1;
}
let mut lo = 0usize;
let mut hi = stops.len() - 1;
while hi - lo > 1 {
let mid = (lo + hi) / 2;
if stops[mid].0 <= t {
lo = mid;
} else {
hi = mid;
}
}
let span = (stops[hi].0 - stops[lo].0).max(0.0001);
let local = (t - stops[lo].0) / span;
let a = stops[lo].1;
let b = stops[hi].1;
Color::rgba(
(a.r as f32 + (b.r as f32 - a.r as f32) * local) as u8,
(a.g as f32 + (b.g as f32 - a.g as f32) * local) as u8,
(a.b as f32 + (b.b as f32 - a.b as f32) * local) as u8,
(a.a as f32 + (b.a as f32 - a.a as f32) * local) as u8,
)
}
fn radius_to_far_corner(centre: Point, width: u32, height: u32) -> f32 {
let cx = centre.x as f32;
let cy = centre.y as f32;
let (w, h) = (width as f32, height as f32);
let mut best: f32 = 0.0;
for corner in [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)] {
best = best.max((corner.0 - cx).hypot(corner.1 - cy));
}
best.max(1.0)
}
fn draw_conic_fan(
backend: &mut SvgPaintBackend,
centre: Point,
radius: f32,
angle_origin: f32,
stops: &[(f32, Color)],
) {
const WEDGES: usize = 360;
let cx = centre.x as f32;
let cy = centre.y as f32;
for i in 0..WEDGES {
let t0 = i as f32 / WEDGES as f32;
let t1 = (i + 1) as f32 / WEDGES as f32;
let colour = conic_sample(stops, (t0 + t1) * 0.5, angle_origin);
let a0 = (t0 * 360.0 - 180.0 - angle_origin.to_degrees()).rem_euclid(360.0);
let a1 = (t1 * 360.0 - 180.0 - angle_origin.to_degrees()).rem_euclid(360.0);
let (s0, c0) = (a0.to_radians().sin(), a0.to_radians().cos());
let (s1, c1) = (a1.to_radians().sin(), a1.to_radians().cos());
let x0 = cx + radius * c0;
let y0 = cy + radius * s0;
let x1 = cx + radius * c1;
let y1 = cy + radius * s1;
let large = if (a1 - a0).rem_euclid(360.0) > 180.0 { 1 } else { 0 };
backend.push_element(format!(
r##"<path d="M {cx} {cy} L {x0} {y0} A {radius} {radius} 0 {large} 1 {x1} {y1} Z" fill="{}" stroke="none" />"##,
color_to_rgba(&colour)
));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(
not(any(feature = "fonts-vector-latin", feature = "fonts-complex", cjk_outline_face)),
allow(dead_code)
)]
enum OutlineOutcome {
Drawn,
ClippedAway,
Uncovered,
}
impl SvgPaintBackend {
#[cfg(any(feature = "fonts-vector-latin", feature = "fonts-complex", cjk_outline_face))]
fn append_outline(
&self,
path: &mut String,
ch: char,
pen_x: f32,
origin_y: i32,
glyph_width: u32,
glyph_height: u32,
) -> OutlineOutcome {
let mut points = [crate::render::text::OutlinePoint { x: 0.0, y: 0.0 };
crate::render::text::OUTLINE_MAX_POINTS];
let mut contours = [(0usize, 0usize); crate::render::text::OUTLINE_MAX_CONTOURS];
let cell = crate::render::text::Cell::new(glyph_width, glyph_height);
let Some(count) =
crate::render::text::outline_by_coverage(ch, cell, &mut points, &mut contours)
else {
return OutlineOutcome::Uncovered;
};
let clip_left = pen_x;
let clip_right = pen_x + glyph_width as f32;
let clip_top = origin_y as f32;
let clip_bottom = clip_top + glyph_height as f32;
let before = path.len();
let capacity = crate::render::text::OUTLINE_MAX_POINTS * 2;
let mut scratch_a: Vec<(f32, f32)> = Vec::with_capacity(capacity);
let mut scratch_b: Vec<(f32, f32)> = Vec::with_capacity(capacity);
for (start, end) in contours.iter().take(count) {
let Some(contour) = points.get(*start..*end) else {
continue;
};
scratch_a.clear();
scratch_a.extend(contour.iter().map(|p| (pen_x + p.x, origin_y as f32 + p.y)));
clip_against_x(&scratch_a, &mut scratch_b, clip_left, true);
core::mem::swap(&mut scratch_a, &mut scratch_b);
if !scratch_a.is_empty() {
clip_against_x(&scratch_a, &mut scratch_b, clip_right, false);
core::mem::swap(&mut scratch_a, &mut scratch_b);
}
if !scratch_a.is_empty() {
clip_against_y(&scratch_a, &mut scratch_b, clip_top, true);
core::mem::swap(&mut scratch_a, &mut scratch_b);
}
if !scratch_a.is_empty() {
clip_against_y(&scratch_a, &mut scratch_b, clip_bottom, false);
core::mem::swap(&mut scratch_a, &mut scratch_b);
}
if scratch_a.len() < 3 {
continue;
}
path.push_str(&format!("M{:.2} {:.2}", scratch_a[0].0, scratch_a[0].1));
for point in scratch_a.iter().skip(1) {
path.push_str(&format!("L{:.2} {:.2}", point.0, point.1));
}
path.push('Z');
}
if path.len() > before {
OutlineOutcome::Drawn
} else {
OutlineOutcome::ClippedAway
}
}
fn append_bitmap_rects(
&self,
path: &mut String,
ch: char,
pen_x: f32,
origin_y: i32,
glyph_width: u32,
glyph_height: u32,
) {
for (x0, y0, x1, y1) in crate::render::glyph_rects(
ch,
pen_x.round() as i32,
origin_y,
glyph_width,
glyph_height,
) {
path.push_str(&format!("M{x0} {y0}h{}v{}h-{}z", x1 - x0, y1 - y0, x1 - x0));
}
}
}
#[cfg(any(feature = "fonts-vector-latin", feature = "fonts-complex", cjk_outline_face))]
fn clip_against_x(
input: &[(f32, f32)],
output: &mut Vec<(f32, f32)>,
limit: f32,
keep_greater: bool,
) {
let inside = |x: f32, _y: f32| if keep_greater { x >= limit } else { x <= limit };
clip_polygon(input, output, inside, |x, _y| x, limit);
}
#[cfg(any(feature = "fonts-vector-latin", feature = "fonts-complex", cjk_outline_face))]
fn clip_against_y(
input: &[(f32, f32)],
output: &mut Vec<(f32, f32)>,
limit: f32,
keep_greater: bool,
) {
let inside = |_x: f32, y: f32| if keep_greater { y >= limit } else { y <= limit };
clip_polygon(input, output, inside, |_x, y| y, limit);
}
#[cfg(any(feature = "fonts-vector-latin", feature = "fonts-complex", cjk_outline_face))]
fn clip_polygon(
input: &[(f32, f32)],
output: &mut Vec<(f32, f32)>,
inside: impl Fn(f32, f32) -> bool,
varying: impl Fn(f32, f32) -> f32,
limit: f32,
) {
output.clear();
if input.is_empty() {
return;
}
for index in 0..input.len() {
let start = input[index];
let end = input[(index + 1) % input.len()];
let start_inside = inside(start.0, start.1);
let end_inside = inside(end.0, end.1);
if end_inside {
if !start_inside {
if let Some(crossing) = edge_crossing(start, end, limit, &varying) {
output.push(crossing);
}
}
output.push(end);
} else if start_inside {
if let Some(crossing) = edge_crossing(start, end, limit, &varying) {
output.push(crossing);
}
}
}
}
#[cfg(any(feature = "fonts-vector-latin", feature = "fonts-complex", cjk_outline_face))]
fn edge_crossing(
start: (f32, f32),
end: (f32, f32),
limit: f32,
varying: &impl Fn(f32, f32) -> f32,
) -> Option<(f32, f32)> {
let span = varying(end.0, end.1) - varying(start.0, start.1);
if span.abs() < f32::EPSILON {
return None;
}
let t = (limit - varying(start.0, start.1)) / span;
if !(0.0..=1.0).contains(&t) {
return None;
}
Some((start.0 + (end.0 - start.0) * t, start.1 + (end.1 - start.1) * t))
}
fn rgba_to_bmp(width: u32, height: u32, rgba: &[u8]) -> Vec<u8> {
let source_pixels = (rgba.len() / 4) as u32;
let (width, height) = if source_pixels == 0 {
(0, 0)
} else if (width * height) as usize == source_pixels as usize && width > 0 && height > 0 {
(width, height)
} else if let Some(derived_width) = source_pixels.checked_div(height) {
(derived_width, height)
} else {
(source_pixels, 1)
};
let row_size = width * 4; let pixel_data_size = row_size * height;
let file_size: usize = 14 + 40 + pixel_data_size as usize;
let mut bmp = Vec::with_capacity(file_size);
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&(file_size as u32).to_le_bytes());
bmp.extend_from_slice(&[0u8; 4]); bmp.extend_from_slice(&54u32.to_le_bytes());
bmp.extend_from_slice(&40u32.to_le_bytes()); bmp.extend_from_slice(&width.to_le_bytes());
bmp.extend_from_slice(&height.to_le_bytes());
bmp.extend_from_slice(&1u16.to_le_bytes()); bmp.extend_from_slice(&32u16.to_le_bytes()); bmp.extend_from_slice(&0u32.to_le_bytes()); bmp.extend_from_slice(&pixel_data_size.to_le_bytes()); bmp.extend_from_slice(&0i32.to_le_bytes()); bmp.extend_from_slice(&0i32.to_le_bytes()); bmp.extend_from_slice(&0u32.to_le_bytes()); bmp.extend_from_slice(&0u32.to_le_bytes());
for y in (0..height).rev() {
let row_off = (y * row_size) as usize;
for x in 0..width {
let idx = row_off + (x * 4) as usize;
if idx + 4 > rgba.len() {
return bmp;
}
bmp.push(rgba[idx + 2]); bmp.push(rgba[idx + 1]); bmp.push(rgba[idx]); bmp.push(rgba[idx + 3]); }
}
bmp
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
let cap = data.len().div_ceil(3) * 4;
let mut out = String::with_capacity(cap);
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let triple = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(CHARS[(triple & 0x3F) as usize] as char);
} else {
out.push('=');
}
}
out
}
impl PaintBackend for SvgPaintBackend {
fn begin_frame(&mut self, clear: Color) {
self.elements.clear();
self.clip_depth = 0;
if clear.a > 0 {
let fill = color_to_rgba(&clear);
let bg_rect = crate::core::Rect::new(0, 0, self.size.width, self.size.height);
self.push_element(format!(r#"<rect {} fill="{}" />"#, rect_attrs(&bg_rect), fill));
}
}
fn end_frame(&mut self) {
for _ in 0..self.clip_depth {
self.push_element("</g>".to_string());
}
self.clip_depth = 0;
self.svg_output = Some(self.build_svg());
}
fn execute_command(&mut self, command: &RenderCommand) {
match command {
RenderCommand::FillRect { rect, color } => {
self.push_element(format!(
r#"<rect {} fill="{}" />"#,
rect_attrs(rect),
color_to_rgba(color)
));
}
RenderCommand::FillRoundedRect { rect, radius, color }
| RenderCommand::FillRoundedRectAA { rect, radius, color } => {
self.push_element(format!(
r#"<rect {} rx="{}" ry="{}" fill="{}" />"#,
rect_attrs(rect),
radius,
radius,
color_to_rgba(color)
));
}
RenderCommand::DrawRect { rect, color } => {
self.push_element(format!(
r#"<rect {} fill="none" stroke="{}" stroke-width="1" />"#,
rect_attrs(rect),
color_to_rgba(color)
));
}
RenderCommand::DrawRectStroke { rect, color, width } => {
self.push_element(format!(
r#"<rect {} fill="none" stroke="{}" stroke-width="{}" />"#,
rect_attrs(rect),
color_to_rgba(color),
width
));
}
RenderCommand::DrawRoundedRectStroke { rect, radius, color, width }
| RenderCommand::DrawRoundedRectStrokeAA { rect, radius, color, width } => {
self.push_element(format!(
r#"<rect {} rx="{}" ry="{}" fill="none" stroke="{}" stroke-width="{}" />"#,
rect_attrs(rect),
radius,
radius,
color_to_rgba(color),
width
));
}
RenderCommand::DrawLine { from, to, color }
| RenderCommand::DrawLineAA { from, to, color } => {
self.push_element(format!(
r#"<line {} x2="{}" y2="{}" stroke="{}" stroke-width="1" />"#,
point_attrs(from),
to.x,
to.y,
color_to_rgba(color)
));
}
RenderCommand::DrawLineStroke { from, to, color, width }
| RenderCommand::DrawLineStrokeAA { from, to, color, width } => {
self.push_element(format!(
r#"<line {} x2="{}" y2="{}" stroke="{}" stroke-width="{}" />"#,
point_attrs(from),
to.x,
to.y,
color_to_rgba(color),
width
));
}
RenderCommand::FillCircle { center, radius, color }
| RenderCommand::FillCircleAA { center, radius, color } => {
self.push_element(format!(
r#"<circle cx="{}" cy="{}" r="{}" fill="{}" />"#,
center.x,
center.y,
radius,
color_to_rgba(color)
));
}
RenderCommand::DrawCircle { center, radius, color } => {
self.push_element(format!(
r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="1" />"#,
center.x,
center.y,
radius,
color_to_rgba(color)
));
}
RenderCommand::DrawCircleStroke { center, radius, color, width } => {
self.push_element(format!(
r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="{}" />"#,
center.x,
center.y,
radius,
color_to_rgba(color),
width
));
}
RenderCommand::DrawText { origin, text, font, color, alignment } => {
let shaped = self.shape_text(text, font);
let glyph_height = self.measure_text(text, font).height.max(1);
let anchor_x = match alignment {
crate::core::HorizontalAlignment::Left => origin.x as f32,
crate::core::HorizontalAlignment::Center => {
origin.x as f32 - shaped.advance() / 2.0
}
crate::core::HorizontalAlignment::Right => origin.x as f32 - shaped.advance(),
};
let mut path = String::new();
let mut pen_x = anchor_x;
for cluster in shaped.clusters() {
let glyph_width = cluster.advance.max(1.0).round() as u32;
let display_char = cluster
.text
.chars()
.find(|ch| !is_combining_mark(*ch) && !is_variation_selector(*ch));
if let Some(ch) = display_char {
#[cfg(any(
feature = "fonts-vector-latin",
feature = "fonts-complex",
cjk_outline_face
))]
let outcome = self.append_outline(
&mut path,
ch,
pen_x,
origin.y,
glyph_width,
glyph_height,
);
#[cfg(not(any(
feature = "fonts-vector-latin",
feature = "fonts-complex",
cjk_outline_face
)))]
let outcome = OutlineOutcome::Uncovered;
if outcome == OutlineOutcome::Uncovered {
self.append_bitmap_rects(
&mut path,
ch,
pen_x,
origin.y,
glyph_width,
glyph_height,
);
}
}
pen_x += cluster.advance;
}
if path.is_empty() {
return;
}
#[cfg(any(
feature = "fonts-vector-latin",
feature = "fonts-complex",
cjk_outline_face
))]
self.push_element(format!(
r#"<path d="{}" fill="{}" fill-rule="nonzero" data-text="1" />"#,
path,
color_to_rgba(color)
));
#[cfg(not(any(
feature = "fonts-vector-latin",
feature = "fonts-complex",
cjk_outline_face
)))]
self.push_element(format!(
r#"<path d="{}" fill="{}" />"#,
path,
color_to_rgba(color)
));
}
RenderCommand::DrawImage { x, y, width, height, data } => {
if !data.is_empty() && *width > 0 && *height > 0 {
let bmp = rgba_to_bmp(*width, *height, data);
let b64 = base64_encode(&bmp);
self.push_element(format!(
r##"<image x="{x}" y="{y}" width="{width}" height="{height}" preserveAspectRatio="none" href="data:image/bmp;base64,{b64}" />"##
));
} else {
self.push_element(format!(
r##"<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="#fee" stroke="#c00" stroke-width="2" />"##
));
let cx = x + (*width as i32) / 2;
let cy = y + (*height as i32) / 2;
self.push_element(format!(
r##"<text x="{cx}" y="{cy}" text-anchor="middle" dominant-baseline="central" font-family="sans-serif" font-size="11" fill="#c00">Image (no data)</text>"##
));
}
}
RenderCommand::PushClip { x, y, width, height } => {
let clip_id = format!("clip_{}", self.clip_depth);
let clip_def = format!(
r#"<clipPath id="{clip_id}"><rect x="{x}" y="{y}" width="{width}" height="{height}" /></clipPath>"#
);
self.push_element(clip_def);
self.push_element(format!(r#"<g clip-path="url(#{clip_id})">"#));
self.clip_depth += 1;
}
RenderCommand::PopClip => {
if self.clip_depth > 0 {
self.push_element("</g>".to_string());
self.clip_depth -= 1;
}
}
RenderCommand::DrawGradient { rect, gradient } => {
if gradient.gradient_type == GradientType::Conic {
let pairs: Vec<(f32, Color)> =
gradient.stops.iter().map(|s| (s.position, s.color)).collect();
if pairs.is_empty() {
return;
}
let clip_id = format!("cg{}", self.gradient_counter);
self.gradient_counter += 1;
self.push_element(format!(
r##"<clipPath id="{clip_id}"><rect x="{}" y="{}" width="{}" height="{}" /></clipPath>"##,
rect.x, rect.y, rect.width, rect.height
));
self.push_element(format!(r##"<g clip-path="url(#{clip_id})">"##));
let radius = radius_to_far_corner(gradient.center, rect.width, rect.height);
draw_conic_fan(self, gradient.center, radius, 0.0, &pairs);
self.push_element("</g>".to_string());
return;
}
self.gradient_counter += 1;
let gid = format!("g{}", self.gradient_counter);
let mut def = String::new();
match gradient.gradient_type {
GradientType::Linear => {
def.push_str(&format!(
r##"<linearGradient id=\"{}\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\">"##,
gid,
gradient.start_point.x,
gradient.start_point.y,
gradient.end_point.x,
gradient.end_point.y
));
}
GradientType::Radial => {
def.push_str(&format!(
r##"<radialGradient id=\"{}\" cx=\"{}\" cy=\"{}\" r=\"{}\">"##,
gid, gradient.center.x, gradient.center.y, gradient.radius
));
}
GradientType::Conic => {
unreachable!("conic gradients return before the paint server")
}
}
for stop in &gradient.stops {
let hex =
format!("#{:02x}{:02x}{:02x}", stop.color.r, stop.color.g, stop.color.b);
let alpha = stop.color.a as f32 / 255.0;
def.push_str(&format!(
r##"<stop offset=\"{:.3}\" stop-color=\"{}\" stop-opacity=\"{:.3}\"/>"##,
stop.position, hex, alpha
));
}
match gradient.gradient_type {
GradientType::Linear => {
def.push_str("</linearGradient>");
}
GradientType::Radial => {
def.push_str("</radialGradient>");
}
GradientType::Conic => unreachable!(),
}
self.push_element(format!("<defs>{def}</defs>"));
self.push_element(format!(
r##"<rect x="{}" y="{}" width="{}" height="{}" fill="url(#{})" />"##,
rect.x, rect.y, rect.width, rect.height, gid
));
}
RenderCommand::DrawArc { center, radius, start_angle, end_angle, color, filled } => {
let large_arc =
if (end_angle - start_angle).abs() > core::f32::consts::PI { 1 } else { 0 };
let start_x = center.x + (*radius as f32 * start_angle.cos()) as i32;
let start_y = center.y + (*radius as f32 * start_angle.sin()) as i32;
let end_x = center.x + (*radius as f32 * end_angle.cos()) as i32;
let end_y = center.y + (*radius as f32 * end_angle.sin()) as i32;
let fill = if *filled { color_to_rgba(color) } else { "none".to_string() };
let stroke = if *filled { "none".to_string() } else { color_to_rgba(color) };
if *filled {
self.push_element(format!(
r##"<path d="M {} {} L {} {} A {} {} 0 {} 1 {} {} Z" fill="{}" stroke="{}" />"##,
center.x, center.y,
start_x, start_y,
radius, radius, large_arc, end_x, end_y,
fill, stroke
));
} else {
self.push_element(format!(
r##"<path d="M {start_x} {start_y} A {radius} {radius} 0 {large_arc} 1 {end_x} {end_y}" fill="{fill}" stroke="{stroke}" />"##
));
}
}
RenderCommand::DrawPath { points, closed, color, filled, width } => {
if points.is_empty() {
return;
}
let mut d = format!("M {} {}", points[0].x, points[0].y);
for pt in &points[1..] {
d.push_str(&format!(" L {} {}", pt.x, pt.y));
}
if *closed {
d.push_str(" Z");
}
let fill = if *filled { color_to_rgba(color) } else { "none".to_string() };
let stroke = if *filled { "none".to_string() } else { color_to_rgba(color) };
self.push_element(format!(
r##"<path d="{d}" fill="{fill}" stroke="{stroke}" stroke-width="{width}" />"##
));
}
RenderCommand::BoxShadow { rect, color, offset_x, offset_y, blur_radius, spread } => {
let spread_w = (rect.width as i32 + *spread * 2).max(0) as u32;
let spread_h = (rect.height as i32 + *spread * 2).max(0) as u32;
let x = rect.x + offset_x - *spread;
let y = rect.y + offset_y - *spread;
let shadow = Color::rgba(color.r, color.g, color.b, (color.a as f32 * 0.5) as u8);
let filter_attr = if *blur_radius > 0 {
let filter_id = format!("shadow_blur_{blur_radius}");
self.push_element(format!(
r##"<filter id="{filter_id}"><feGaussianBlur stdDeviation="{blur_radius}" /></filter>"##
));
format!(r##" filter="url(#{filter_id})""##)
} else {
String::new()
};
self.push_element(format!(
r##"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"{} />"##,
x,
y,
spread_w,
spread_h,
color_to_rgba(&shadow),
filter_attr
));
}
RenderCommand::Blur { radius } => {
self.close_blur_group();
if *radius > 0 {
let filter_id = format!("blur_{radius}");
self.push_element(format!(
r##"<filter id="{filter_id}"><feGaussianBlur stdDeviation="{radius}" /></filter>"##
));
self.push_element(format!(r##"<g filter="url(#{filter_id})">"##));
self.blur_group_open = true;
}
}
RenderCommand::ClipPath { points } => {
if !points.is_empty() {
let clip_id = format!("cp_{}", self.clip_path_counter);
self.clip_path_counter += 1;
let mut d = format!("M {} {}", points[0].x, points[0].y);
for pt in &points[1..] {
d.push_str(&format!(" L {} {}", pt.x, pt.y));
}
d.push_str(" Z");
self.push_element(format!(
r##"<clipPath id=\"{clip_id}\"><path d=\"{d}\" /></clipPath>"##
));
self.push_element(format!(r##"<g clip-path=\"url(#{clip_id})\">"##));
self.clip_depth += 1;
}
}
RenderCommand::SetBlendMode { mode } => {
self.close_blend_group();
if *mode != BlendMode::Normal {
self.push_element(format!(
r##"<g style="mix-blend-mode:{}">"##,
blend_mode_to_css(*mode)
));
self.blend_group_open = true;
}
}
RenderCommand::DrawConicGradient { center, start_angle, stops } => {
if stops.is_empty() {
return;
}
let radius = radius_to_far_corner(*center, self.size.width, self.size.height);
draw_conic_fan(self, *center, radius, *start_angle, stops);
}
}
}
fn size(&self) -> Size {
self.size
}
fn set_size(&mut self, size: Size) {
self.size = size;
}
fn dpi_scale(&self) -> f32 {
self.dpi_scale
}
fn set_dpi_scale(&mut self, dpi_scale: f32) {
self.dpi_scale = dpi_scale;
}
fn measure_text(&self, text: &str, font: &Font) -> TextMetrics {
let scale = self.dpi_scale;
let shaped = self.shape_text(text, font);
let width = shaped.advance().round() as u32;
TextMetrics { width, ..TextMetrics::for_font(font, scale) }
}
fn shape_text(&self, text: &str, font: &Font) -> ShapedText {
crate::render::text::shape_line(text, font, self.dpi_scale)
}
fn frame_rgba(&self) -> &[u8] {
&[]
}
fn apply_render_config(&mut self, _config: SoftwareRenderConfig) {}
fn render_config(&self) -> SoftwareRenderConfig {
SoftwareRenderConfig::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
#[test]
fn svg_backend_creates_valid_document() {
let mut svg = SvgPaintBackend::new(Size::new(100, 50));
svg.begin_frame(Color::WHITE);
svg.execute_command(&RenderCommand::FillRect {
rect: Rect::new(10, 10, 30, 20),
color: Color::rgb(255, 0, 0),
});
svg.end_frame();
let result = svg.finish();
assert!(result.contains("<svg"));
assert!(result.contains("</svg>"));
assert!(result.contains("fill=\"rgba(255,0,0,1.00)\""));
}
#[test]
fn svg_backend_text_is_glyph_geometry_not_a_text_element() {
let mut svg = SvgPaintBackend::new(Size::new(100, 50));
svg.begin_frame(Color::WHITE);
svg.execute_command(&RenderCommand::DrawText {
origin: Point::new(10, 20),
text: "<hello> & world".to_string(),
font: Font::default_ui(),
color: Color::BLACK,
alignment: HorizontalAlignment::Left,
});
svg.end_frame();
let result = svg.finish();
assert!(
!result.contains("<text"),
"a `<text>` element would be rendered by the viewer's font, not by this crate"
);
assert!(result.contains("<path d=\"M"), "the string must be drawn as glyph geometry");
}
#[cfg(any(feature = "fonts-vector-latin", cjk_outline_face))]
#[test]
fn svg_backend_emits_an_outline_for_a_vector_face() {
let family =
if cfg!(feature = "fonts-vector-latin") { "Open Sans" } else { "Noto Sans SC" };
let mut svg = SvgPaintBackend::new(Size::new(120, 48));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::DrawText {
origin: Point::new(4, 4),
text: "A".to_string(),
font: Font::new(family, 32.0, false, false),
color: Color::WHITE,
alignment: HorizontalAlignment::Left,
});
svg.end_frame();
let result = svg.finish();
let path = result
.split("<path d=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("the backend emitted a glyph path");
assert!(
path.contains('L') && path.contains('.'),
"a vector face must be emitted as an outline (`L` commands, fractional coordinates), \
not as bitmap rectangles; got: {path}"
);
assert!(
result.contains("fill-rule=\"nonzero\""),
"an outline path must carry the non-zero fill rule so a counter stays a hole"
);
}
#[cfg(not(any(feature = "fonts-vector-latin", feature = "fonts-complex", cjk_outline_face)))]
#[test]
fn svg_backend_emits_rectangles_without_a_fill_rule_by_default() {
let mut svg = SvgPaintBackend::new(Size::new(120, 48));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::DrawText {
origin: Point::new(4, 4),
text: "A".to_string(),
font: Font::new("Arial", 32.0, false, false),
color: Color::WHITE,
alignment: HorizontalAlignment::Left,
});
svg.end_frame();
let result = svg.finish();
let path = result
.split("<path d=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("the backend emitted a glyph path");
assert!(
!path.contains('L') && !path.contains('.'),
"the 8x8 face's ink is whole pixels, so its subpaths are integer runs with no `L`; \
got: {path}"
);
assert!(
!result.contains("fill-rule"),
"the default build must not gain an attribute: it would rewrite every committed snapshot"
);
}
#[test]
fn svg_backend_honours_horizontal_alignment() {
let _theme_guard = crate::style::theme_test_guard();
let font = Font::simple("Arial", 12.0);
let origin = Point::new(100, 20);
let region = |alignment| {
let mut svg = SvgPaintBackend::new(Size::new(200, 50));
svg.begin_frame(Color::WHITE);
svg.execute_command(&RenderCommand::DrawText {
origin,
text: "Cancel".to_string(),
font: font.clone(),
color: Color::BLACK,
alignment,
});
svg.end_frame();
svg.finish()
};
let width = {
let svg = SvgPaintBackend::new(Size::new(200, 50));
svg.measure_text("Cancel", &font).width as i32
};
assert!(width > 0, "the fixture must have a measurable label");
let first_ink_x = |svg: &str| -> i32 {
let d = svg.find("<path d=\"").expect("the backend emitted a glyph path") + 9;
let end = svg[d..].find('"').expect("the attribute is closed") + d;
svg[d..end]
.split('M')
.skip(1)
.filter_map(|sub| sub.split([' ', 'h', 'L']).next()?.parse::<f32>().ok())
.map(|x| x.round() as i32)
.min()
.expect("the path has at least one subpath")
};
let left = first_ink_x(®ion(HorizontalAlignment::Left));
let centre = first_ink_x(®ion(HorizontalAlignment::Center));
let right = first_ink_x(®ion(HorizontalAlignment::Right));
let total_advance = {
let svg = SvgPaintBackend::new(Size::new(200, 50));
svg.shape_text("Cancel", &font).advance()
};
const SLACK: i32 = 1;
let expected_centre = -(total_advance / 2.0).round() as i32;
assert!(
(centre - left - expected_centre).abs() <= SLACK,
"centre shifts half the measured advance to the left: got {} want {expected_centre}",
centre - left
);
let expected_right = -total_advance.round() as i32;
assert!(
(right - left - expected_right).abs() <= SLACK,
"right shifts the whole measured advance to the left: got {} want {expected_right}",
right - left
);
assert!(left >= origin.x - SLACK, "left-aligned ink starts at or after the origin");
}
#[test]
fn svg_backend_all_command_types() {
let mut svg = SvgPaintBackend::new(Size::new(200, 200));
svg.begin_frame(Color::WHITE);
svg.execute_command(&RenderCommand::FillRect {
rect: Rect::new(0, 0, 50, 50),
color: Color::RED,
});
svg.execute_command(&RenderCommand::FillRoundedRect {
rect: Rect::new(50, 0, 50, 50),
radius: 5,
color: Color::GREEN,
});
svg.execute_command(&RenderCommand::DrawRect {
rect: Rect::new(0, 50, 50, 50),
color: Color::BLUE,
});
svg.execute_command(&RenderCommand::DrawRectStroke {
rect: Rect::new(50, 50, 50, 50),
color: Color::BLACK,
width: 2,
});
svg.execute_command(&RenderCommand::DrawLine {
from: Point::new(0, 100),
to: Point::new(50, 150),
color: Color::RED,
});
svg.execute_command(&RenderCommand::DrawLineStroke {
from: Point::new(50, 100),
to: Point::new(100, 150),
color: Color::GREEN,
width: 3,
});
svg.execute_command(&RenderCommand::FillCircle {
center: Point::new(30, 130),
radius: 15,
color: Color::BLUE,
});
svg.execute_command(&RenderCommand::DrawCircle {
center: Point::new(80, 130),
radius: 15,
color: Color::BLACK,
});
svg.execute_command(&RenderCommand::DrawCircleStroke {
center: Point::new(130, 130),
radius: 15,
color: Color::RED,
width: 2,
});
svg.execute_command(&RenderCommand::DrawText {
origin: Point::new(10, 180),
text: "Test".to_string(),
font: Font::default_ui(),
color: Color::BLACK,
alignment: HorizontalAlignment::Left,
});
svg.execute_command(&RenderCommand::PushClip { x: 0, y: 0, width: 100, height: 100 });
svg.execute_command(&RenderCommand::PopClip);
svg.execute_command(&RenderCommand::DrawImage {
x: 100,
y: 0,
width: 50,
height: 50,
data: vec![],
});
svg.end_frame();
let result = svg.finish();
assert!(result.contains("<svg"));
assert!(result.contains("</svg>"));
assert!(result.contains("stroke"));
assert!(result.contains("fill"));
assert!(result.contains("Image (no data)"));
assert!(result.contains("#fee"));
assert!(result.contains("#c00"));
}
#[test]
fn svg_backend_begin_frame_clears() {
let mut svg = SvgPaintBackend::new(Size::new(10, 10));
svg.begin_frame(Color::rgb(200, 200, 200));
assert!(svg.elements.len() == 1);
svg.end_frame();
let result = svg.finish();
assert!(result.contains("200"));
}
#[test]
fn svg_backend_draw_rounded_rect_stroke() {
let mut svg = SvgPaintBackend::new(Size::new(100, 100));
svg.begin_frame(Color::WHITE);
svg.execute_command(&RenderCommand::DrawRoundedRectStroke {
rect: Rect::new(10, 10, 80, 80),
radius: 8,
color: Color::BLUE,
width: 2,
});
svg.end_frame();
let result = svg.finish();
assert!(result.contains("rx=\"8\""));
assert!(result.contains("ry=\"8\""));
assert!(result.contains("stroke-width=\"2\""));
}
#[test]
fn svg_backend_draw_line_aa() {
let mut svg = SvgPaintBackend::new(Size::new(100, 100));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::DrawLineAA {
from: Point::new(5, 5),
to: Point::new(95, 95),
color: Color::RED,
});
svg.end_frame();
let result = svg.finish();
assert!(result.contains("x1=\"5\""));
assert!(result.contains("y1=\"5\""));
assert!(result.contains("x2=\"95\""));
assert!(result.contains("y2=\"95\""));
}
#[test]
fn svg_backend_fill_circle_aa() {
let mut svg = SvgPaintBackend::new(Size::new(100, 100));
svg.begin_frame(Color::WHITE);
svg.execute_command(&RenderCommand::FillCircleAA {
center: Point::new(50, 50),
radius: 25,
color: Color::GREEN,
});
svg.end_frame();
let result = svg.finish();
assert!(result.contains("<circle"));
assert!(result.contains("cx=\"50\""));
assert!(result.contains("r=\"25\""));
}
#[test]
fn svg_backend_multiple_frames() {
let mut svg = SvgPaintBackend::new(Size::new(50, 50));
svg.begin_frame(Color::WHITE);
svg.execute_command(&RenderCommand::FillRect {
rect: Rect::new(0, 0, 25, 25),
color: Color::RED,
});
svg.end_frame();
let frame1 = svg.finish();
assert!(frame1.contains("rgba(255,0,0"));
svg.begin_frame(Color::BLACK);
assert!(svg.elements.len() == 1);
svg.end_frame();
let frame2 = svg.finish();
assert!(frame2.contains("rgba(0,0,0"));
}
#[test]
fn svg_backend_clip_nesting() {
let mut svg = SvgPaintBackend::new(Size::new(100, 100));
svg.begin_frame(Color::WHITE);
svg.execute_command(&RenderCommand::PushClip { x: 10, y: 10, width: 50, height: 50 });
svg.execute_command(&RenderCommand::PushClip { x: 20, y: 20, width: 30, height: 30 });
svg.execute_command(&RenderCommand::PopClip);
svg.execute_command(&RenderCommand::PopClip);
svg.end_frame();
let result = svg.finish();
assert!(result.contains("clip_0"));
assert!(result.contains("clip_1"));
}
#[test]
fn svg_backend_draws_a_conic_gradient_as_wedges() {
let mut svg = SvgPaintBackend::new(Size::new(120, 120));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::DrawConicGradient {
center: Point::new(60, 60),
start_angle: 0.0,
stops: vec![
(0.0, Color::rgb(255, 0, 0)),
(0.5, Color::rgb(0, 255, 0)),
(1.0, Color::rgb(0, 0, 255)),
],
});
svg.end_frame();
let result = svg.finish();
assert!(
!result.contains("<linearGradient"),
"a conic must not be silently approximated by a linear gradient"
);
let wedges = result.matches("<path d=\"M 60 60 L").count();
assert!(wedges >= 300, "the sweep is drawn as a wedge fan; got {wedges} wedges");
let filler = |needle: &str| result.matches(needle).count();
assert!(filler("rgba(2") > 0, "the red end of the ramp is drawn");
assert!(
filler("rgba(0,0,") > 0 || result.contains("rgba(0,0,255"),
"the blue end is drawn"
);
}
#[test]
fn a_covered_glyph_whose_ink_clips_away_draws_nothing_not_a_bitmap() {
let has_outline =
cfg!(any(feature = "fonts-vector-latin", feature = "fonts-complex", cjk_outline_face));
if !has_outline {
return;
}
let mut svg = SvgPaintBackend::new(Size::new(40, 40));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::DrawText {
origin: Point::new(0, 0),
text: "i".to_string(),
font: Font::new("Arial", 12.0, false, false),
color: Color::BLACK,
alignment: HorizontalAlignment::Left,
});
svg.end_frame();
let document = svg.finish();
assert!(
!document.contains("h1v1h-1z") && !document.contains("h1v2h-1z"),
"a covered glyph whose outline clips away must not fall back to bitmap ink; got: {document}"
);
}
#[test]
fn radius_to_far_corner_reaches_every_corner() {
for centre in [Point::new(0, 0), Point::new(5, 7), Point::new(10, 10), Point::new(12, 3)] {
let r = radius_to_far_corner(centre, 10, 10);
let (cx, cy) = (centre.x as f32, centre.y as f32);
for corner in [(0.0, 0.0), (10.0, 0.0), (0.0, 10.0), (10.0, 10.0)] {
let d = (corner.0 - cx).hypot(corner.1 - cy);
assert!(r >= d, "radius {r} covers corner at distance {d} from {centre:?}");
}
}
let r = radius_to_far_corner(Point::new(0, 0), 10, 10);
assert!((r - (200.0f32).sqrt()).abs() < 0.001, "the diagonal is the farthest corner: {r}");
}
#[test]
fn every_render_command_variant_reaches_the_svg_backend() {
fn name_of(command: &RenderCommand) -> &'static str {
match command {
RenderCommand::FillRect { .. } => "FillRect",
RenderCommand::DrawRect { .. } => "DrawRect",
RenderCommand::DrawRectStroke { .. } => "DrawRectStroke",
RenderCommand::FillRoundedRect { .. } => "FillRoundedRect",
RenderCommand::FillRoundedRectAA { .. } => "FillRoundedRectAA",
RenderCommand::DrawRoundedRectStroke { .. } => "DrawRoundedRectStroke",
RenderCommand::DrawRoundedRectStrokeAA { .. } => "DrawRoundedRectStrokeAA",
RenderCommand::DrawLine { .. } => "DrawLine",
RenderCommand::DrawLineAA { .. } => "DrawLineAA",
RenderCommand::DrawLineStroke { .. } => "DrawLineStroke",
RenderCommand::DrawLineStrokeAA { .. } => "DrawLineStrokeAA",
RenderCommand::FillCircle { .. } => "FillCircle",
RenderCommand::FillCircleAA { .. } => "FillCircleAA",
RenderCommand::DrawCircle { .. } => "DrawCircle",
RenderCommand::DrawCircleStroke { .. } => "DrawCircleStroke",
RenderCommand::DrawText { .. } => "DrawText",
RenderCommand::DrawImage { .. } => "DrawImage",
RenderCommand::PushClip { .. } => "PushClip",
RenderCommand::PopClip => "PopClip",
RenderCommand::DrawGradient { .. } => "DrawGradient",
RenderCommand::DrawArc { .. } => "DrawArc",
RenderCommand::DrawPath { .. } => "DrawPath",
RenderCommand::BoxShadow { .. } => "BoxShadow",
RenderCommand::Blur { .. } => "Blur",
RenderCommand::ClipPath { .. } => "ClipPath",
RenderCommand::SetBlendMode { .. } => "SetBlendMode",
RenderCommand::DrawConicGradient { .. } => "DrawConicGradient",
}
}
let rect = Rect::new(20, 20, 60, 40);
let point = Point::new(40, 40);
let commands: Vec<RenderCommand> = vec![
RenderCommand::FillRect { rect, color: Color::RED },
RenderCommand::DrawRect { rect, color: Color::GREEN },
RenderCommand::DrawRectStroke { rect, color: Color::BLUE, width: 2 },
RenderCommand::FillRoundedRect { rect, radius: 6, color: Color::RED },
RenderCommand::FillRoundedRectAA { rect, radius: 6, color: Color::GREEN },
RenderCommand::DrawRoundedRectStroke { rect, radius: 6, color: Color::BLUE, width: 2 },
RenderCommand::DrawRoundedRectStrokeAA { rect, radius: 6, color: Color::RED, width: 2 },
RenderCommand::DrawLine { from: point, to: Point::new(80, 80), color: Color::BLUE },
RenderCommand::DrawLineAA { from: point, to: Point::new(80, 80), color: Color::RED },
RenderCommand::DrawLineStroke {
from: point,
to: Point::new(80, 80),
color: Color::GREEN,
width: 3,
},
RenderCommand::DrawLineStrokeAA {
from: point,
to: Point::new(80, 80),
color: Color::RED,
width: 3,
},
RenderCommand::FillCircle { center: point, radius: 20, color: Color::BLUE },
RenderCommand::FillCircleAA { center: point, radius: 20, color: Color::RED },
RenderCommand::DrawCircle { center: point, radius: 20, color: Color::GREEN },
RenderCommand::DrawCircleStroke {
center: point,
radius: 20,
color: Color::BLUE,
width: 2,
},
RenderCommand::DrawText {
origin: point,
text: "Hi".to_string(),
font: Font::default_ui(),
color: Color::BLACK,
alignment: HorizontalAlignment::Left,
},
RenderCommand::DrawImage {
x: 20,
y: 20,
width: 40,
height: 40,
data: vec![1, 2, 3, 4],
},
RenderCommand::PushClip { x: 10, y: 10, width: 50, height: 50 },
RenderCommand::PopClip,
RenderCommand::DrawGradient {
rect,
gradient: crate::style::Gradient::linear(point, Point::new(80, 40))
.add_stop(0.0, Color::RED)
.add_stop(1.0, Color::BLUE),
},
RenderCommand::DrawGradient {
rect,
gradient: crate::style::Gradient::radial(point, 40.0)
.add_stop(0.0, Color::GREEN)
.add_stop(1.0, Color::BLUE),
},
RenderCommand::DrawGradient {
rect,
gradient: crate::style::Gradient::conic(point, 0.0)
.add_stop(0.0, Color::RED)
.add_stop(1.0, Color::BLUE),
},
RenderCommand::DrawArc {
center: point,
radius: 20,
start_angle: 0.0,
end_angle: 1.5,
color: Color::RED,
filled: false,
},
RenderCommand::DrawArc {
center: point,
radius: 20,
start_angle: 0.0,
end_angle: 1.5,
color: Color::BLUE,
filled: true,
},
RenderCommand::DrawPath {
points: vec![Point::new(10, 10), Point::new(60, 20), Point::new(40, 70)],
closed: true,
color: Color::GREEN,
filled: false,
width: 2,
},
RenderCommand::DrawPath {
points: vec![Point::new(10, 10), Point::new(60, 20), Point::new(40, 70)],
closed: false,
color: Color::GREEN,
filled: true,
width: 2,
},
RenderCommand::BoxShadow {
rect,
color: Color::rgba(0, 0, 0, 128),
offset_x: 2,
offset_y: 2,
blur_radius: 4,
spread: 1,
},
RenderCommand::Blur { radius: 3 },
RenderCommand::ClipPath {
points: vec![Point::new(5, 5), Point::new(95, 5), Point::new(95, 95)],
},
RenderCommand::SetBlendMode { mode: BlendMode::Multiply },
RenderCommand::DrawConicGradient {
center: point,
start_angle: 0.0,
stops: vec![(0.0, Color::RED), (1.0, Color::BLUE)],
},
];
const OUTPUTLESS: &[&str] = &[
"PopClip",
];
let mut exempted: Vec<&'static str> = Vec::new();
for command in &commands {
let name = name_of(command);
let mut svg = SvgPaintBackend::new(Size::new(120, 120));
svg.begin_frame(Color::TRANSPARENT);
let before = svg.elements.len();
svg.execute_command(command);
svg.end_frame();
let document = svg.finish();
assert_eq!(
document.matches("<g").count(),
document.matches("</g>").count(),
"{name} left an unbalanced `<g>` group in the document"
);
if svg.elements.len() > before {
continue;
}
assert!(
OUTPUTLESS.contains(&name),
"{name} emitted nothing: a command the software backend executes must reach the \
SVG document, or be registered in `OUTPUTLESS` with a reason"
);
exempted.push(name);
}
for name in OUTPUTLESS {
assert!(
exempted.contains(name),
"`OUTPUTLESS` names {name}, but it emitted markup after all; remove the exemption"
);
}
let covered: std::collections::BTreeSet<&'static str> =
commands.iter().map(name_of).collect();
let expected: std::collections::BTreeSet<&'static str> = [
"FillRect",
"DrawRect",
"DrawRectStroke",
"FillRoundedRect",
"FillRoundedRectAA",
"DrawRoundedRectStroke",
"DrawRoundedRectStrokeAA",
"DrawLine",
"DrawLineAA",
"DrawLineStroke",
"DrawLineStrokeAA",
"FillCircle",
"FillCircleAA",
"DrawCircle",
"DrawCircleStroke",
"DrawText",
"DrawImage",
"PushClip",
"PopClip",
"DrawGradient",
"DrawArc",
"DrawPath",
"BoxShadow",
"Blur",
"ClipPath",
"SetBlendMode",
"DrawConicGradient",
]
.into_iter()
.collect();
assert_eq!(
covered, expected,
"the fixture list must drive every `RenderCommand` variant exactly once or more"
);
}
#[test]
fn svg_backend_blur_wraps_subsequent_drawing_in_a_filter() {
let mut svg = SvgPaintBackend::new(Size::new(100, 100));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::Blur { radius: 4 });
svg.execute_command(&RenderCommand::FillRect {
rect: Rect::new(10, 10, 40, 40),
color: Color::BLUE,
});
svg.end_frame();
let result = svg.finish();
assert!(result.contains("<filter id=\"blur_4\""), "the blur defines its filter");
let open = result.find("<g filter=\"url(#blur_4)\">").expect("the blur opens a group");
let rect = result.find("rgba(0,0,255").expect("the drawing is emitted");
assert!(open < rect, "the blurred drawing is inside the filtered group");
let mut svg = SvgPaintBackend::new(Size::new(100, 100));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::Blur { radius: 2 });
svg.execute_command(&RenderCommand::Blur { radius: 3 });
svg.end_frame();
let result = svg.finish();
assert_eq!(
result.matches("<g filter=").count(),
2,
"both blur openings are emitted, one per `Blur` command"
);
assert_eq!(
result.matches("</g>").count(),
2,
"each opening is closed: the second `Blur` closes the first group, and `build_svg` \
closes the last one, so the document is balanced"
);
}
#[test]
fn svg_backend_blend_mode_opens_a_styled_group() {
let mut svg = SvgPaintBackend::new(Size::new(100, 100));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::SetBlendMode { mode: BlendMode::Multiply });
svg.execute_command(&RenderCommand::FillRect {
rect: Rect::new(10, 10, 40, 40),
color: Color::RED,
});
svg.end_frame();
let result = svg.finish();
assert!(
result.contains("<g style=\"mix-blend-mode:multiply\">"),
"the mode maps to its CSS counterpart"
);
let open = result.find("mix-blend-mode:multiply").expect("the group is open");
let rect = result.find("rgba(255,0,0").expect("the drawing is emitted");
assert!(open < rect, "the drawing follows inside the blended group");
let mut svg = SvgPaintBackend::new(Size::new(100, 100));
svg.begin_frame(Color::TRANSPARENT);
svg.execute_command(&RenderCommand::SetBlendMode { mode: BlendMode::Screen });
svg.execute_command(&RenderCommand::SetBlendMode { mode: BlendMode::Normal });
svg.end_frame();
let result = svg.finish();
assert_eq!(result.matches("mix-blend-mode:").count(), 1);
assert_eq!(result.matches("</g>").count(), 1, "`Normal` closed the previous group");
}
}