use super::convert::{color_to_rgba, point_attrs, rect_attrs};
use crate::compat::{format, MiniToString, String, Vec};
use crate::core::{Color, Font, Size};
use crate::render::core::command::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,
}
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,
}
}
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);
}
svg.push_str("\n</svg>");
svg
}
fn push_element(&mut self, element: String) {
self.elements.push(element);
}
#[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,
) -> bool {
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 false;
};
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');
}
path.len() > before
}
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 outlined = 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 outlined = false;
if !outlined {
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 } => {
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 => {
def.push_str(&format!(
r##"<linearGradient id="{}" x1="{}" y1="{}" x2="{}" y2="{}">"##,
gid,
rect.x as f32,
rect.y as f32,
(rect.x + rect.width as i32) as f32,
(rect.y + rect.height as i32) as f32
));
}
}
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 | GradientType::Conic => {
def.push_str("</linearGradient>");
}
GradientType::Radial => {
def.push_str("</radialGradient>");
}
}
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 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=\"{}\"{} rx=\"4\" />"##,
x, y, spread_w, spread_h, color_to_rgba(color), filter_attr
));
}
RenderCommand::Blur { radius } => {
self.push_element(format!(
r##"<filter id="blur_{radius}"><feGaussianBlur stdDeviation="{radius}" /></filter>"##
));
}
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: _ } => {
}
RenderCommand::DrawConicGradient { center: _, 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()
};
assert_eq!(
centre - left,
-(total_advance / 2.0).round() as i32,
"centre shifts half the measured advance to the left"
);
assert_eq!(
right - left,
-total_advance.round() as i32,
"right shifts the whole measured advance to the left"
);
assert!(left >= origin.x, "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"));
}
}