use core::fmt::Write as _;
use kurbo::{Affine, BezPath, Rect, Stroke};
use pdfrum_page::BlendMode;
use pdfrum_render::{AntiAlias, FillRule};
use crate::xml;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Open {
Clip,
Layer,
}
#[derive(Debug, Default)]
pub struct Svg {
defs: String,
body: String,
stack: Vec<Open>,
next_id: usize,
size: (u32, u32),
}
impl Svg {
pub fn new(width: u32, height: u32) -> Self {
Self {
size: (width, height),
..Self::default()
}
}
fn id(&mut self) -> String {
let id = self.next_id;
self.next_id += 1;
format!("c{id}")
}
fn indent(&self) -> String {
" ".repeat(self.stack.len() + 1)
}
fn rendering(aa: AntiAlias) -> Option<&'static str> {
match aa {
AntiAlias::On => None,
AntiAlias::Off | AntiAlias::FullCover => Some("crispEdges"),
}
}
fn rule_name(rule: FillRule) -> &'static str {
match rule {
FillRule::Winding => "nonzero",
FillRule::EvenOdd => "evenodd",
}
}
fn blend_name(blend: BlendMode) -> Option<&'static str> {
match blend {
BlendMode::Normal | BlendMode::Compatible => None,
BlendMode::Multiply => Some("multiply"),
BlendMode::Screen => Some("screen"),
BlendMode::Overlay => Some("overlay"),
BlendMode::Darken => Some("darken"),
BlendMode::Lighten => Some("lighten"),
BlendMode::ColorDodge => Some("color-dodge"),
BlendMode::ColorBurn => Some("color-burn"),
BlendMode::HardLight => Some("hard-light"),
BlendMode::SoftLight => Some("soft-light"),
BlendMode::Difference => Some("difference"),
BlendMode::Exclusion => Some("exclusion"),
BlendMode::Hue => Some("hue"),
BlendMode::Saturation => Some("saturation"),
BlendMode::Color => Some("color"),
BlendMode::Luminosity => Some("luminosity"),
}
}
pub fn fill_path(
&mut self,
path: &BezPath,
t: Affine,
color: peniko::Color,
rule: FillRule,
aa: AntiAlias,
) {
let indent = self.indent();
let _ = write!(
self.body,
"{indent}<path d=\"{}\" fill=\"{}\"",
xml::path_data(path),
xml::rgb_hex(color)
);
self.write_opacity("fill-opacity", xml::alpha_of(color));
if matches!(rule, FillRule::EvenOdd) {
let _ = write!(self.body, " fill-rule=\"{}\"", Self::rule_name(rule));
}
self.write_transform(t);
self.write_rendering(aa);
self.body.push_str("/>\n");
}
pub fn stroke_path(
&mut self,
path: &BezPath,
t: Affine,
color: peniko::Color,
stroke: &Stroke,
aa: AntiAlias,
) {
let indent = self.indent();
let _ = write!(
self.body,
"{indent}<path d=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"",
xml::path_data(path),
xml::rgb_hex(color)
);
xml::number(&mut self.body, stroke.width);
self.body.push('"');
self.write_opacity("stroke-opacity", xml::alpha_of(color));
let _ = write!(
self.body,
" stroke-linecap=\"{}\" stroke-linejoin=\"{}\"",
cap_name(stroke.start_cap),
join_name(stroke.join)
);
if matches!(stroke.join, kurbo::Join::Miter) {
self.body.push_str(" stroke-miterlimit=\"");
xml::number(&mut self.body, stroke.miter_limit);
self.body.push('"');
}
if !stroke.dash_pattern.is_empty() {
self.body.push_str(" stroke-dasharray=\"");
for (i, d) in stroke.dash_pattern.iter().enumerate() {
if i > 0 {
self.body.push(' ');
}
xml::number(&mut self.body, *d);
}
self.body.push_str("\" stroke-dashoffset=\"");
xml::number(&mut self.body, stroke.dash_offset);
self.body.push('"');
}
self.write_transform(t);
self.write_rendering(aa);
self.body.push_str("/>\n");
}
pub fn draw_image(&mut self, png: &[u8], width: u32, height: u32, t: Affine, alpha: f32) {
let indent = self.indent();
let _ = write!(
self.body,
"{indent}<image x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" \
preserveAspectRatio=\"none\" xlink:href=\"data:image/png;base64,"
);
let encoded = xml::base64(png);
xml::escape(&mut self.body, &encoded);
self.body.push('"');
self.write_opacity("opacity", alpha);
self.write_transform(t);
self.body.push_str("/>\n");
}
pub fn draw_raster_region(
&mut self,
png: &[u8],
width: u32,
height: u32,
t: Affine,
alpha: f32,
cause: crate::RasterCause,
) {
let indent = self.indent();
let _ = writeln!(self.body, "{indent}<g data-cause=\"{}\">", cause.name());
self.stack.push(Open::Layer);
self.draw_image(png, width, height, t, alpha);
self.stack.pop();
let _ = writeln!(self.body, "{indent}</g>");
}
pub fn push_clip(&mut self, path: &BezPath, rule: FillRule) {
let id = self.id();
let indent = self.indent();
let _ = write!(
self.defs,
" <clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">\
<path d=\"{}\"",
xml::path_data(path)
);
if matches!(rule, FillRule::EvenOdd) {
let _ = write!(self.defs, " clip-rule=\"{}\"", Self::rule_name(rule));
}
self.defs.push_str("/></clipPath>\n");
let _ = writeln!(self.body, "{indent}<g clip-path=\"url(#{id})\">");
self.stack.push(Open::Clip);
}
pub fn push_clip_rect(&mut self, rect: Rect) {
let id = self.id();
let indent = self.indent();
let _ = write!(
self.defs,
" <clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\"><rect x=\""
);
xml::number(&mut self.defs, rect.x0);
self.defs.push_str("\" y=\"");
xml::number(&mut self.defs, rect.y0);
self.defs.push_str("\" width=\"");
xml::number(&mut self.defs, rect.width().max(0.0));
self.defs.push_str("\" height=\"");
xml::number(&mut self.defs, rect.height().max(0.0));
self.defs.push_str("\"/></clipPath>\n");
let _ = writeln!(self.body, "{indent}<g clip-path=\"url(#{id})\">");
self.stack.push(Open::Clip);
}
pub fn push_layer(&mut self, blend: BlendMode, alpha: f32) {
let indent = self.indent();
let _ = write!(self.body, "{indent}<g");
self.write_opacity("opacity", alpha);
if let Some(name) = Self::blend_name(blend) {
let _ = write!(self.body, " style=\"mix-blend-mode:{name}\"");
}
self.body.push_str(">\n");
self.stack.push(Open::Layer);
}
pub fn pop(&mut self) {
if self.stack.pop().is_some() {
let indent = self.indent();
let _ = writeln!(self.body, "{indent}</g>");
}
}
fn write_transform(&mut self, t: Affine) {
if let Some(m) = xml::matrix(t) {
let _ = write!(self.body, " transform=\"{m}\"");
}
}
fn write_opacity(&mut self, name: &str, alpha: f32) {
if alpha < 1.0 {
let _ = write!(self.body, " {name}=\"");
xml::number(&mut self.body, f64::from(alpha.max(0.0)));
self.body.push('"');
}
}
fn write_rendering(&mut self, aa: AntiAlias) {
if let Some(mode) = Self::rendering(aa) {
let _ = write!(self.body, " shape-rendering=\"{mode}\"");
}
}
pub fn finish(mut self) -> String {
while !self.stack.is_empty() {
self.pop();
}
let (w, h) = self.size;
let mut out = String::with_capacity(self.defs.len() + self.body.len() + 256);
let _ = writeln!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" \
xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">"
);
if !self.defs.is_empty() {
out.push_str(" <defs>\n");
out.push_str(&self.defs);
out.push_str(" </defs>\n");
}
out.push_str(&self.body);
out.push_str("</svg>\n");
out
}
}
fn cap_name(cap: kurbo::Cap) -> &'static str {
match cap {
kurbo::Cap::Butt => "butt",
kurbo::Cap::Round => "round",
kurbo::Cap::Square => "square",
}
}
fn join_name(join: kurbo::Join) -> &'static str {
match join {
kurbo::Join::Bevel => "bevel",
kurbo::Join::Miter => "miter",
kurbo::Join::Round => "round",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn square() -> BezPath {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((4.0, 0.0));
p.line_to((4.0, 4.0));
p.close_path();
p
}
#[test]
fn an_empty_document_is_a_well_formed_root() {
let out = Svg::new(10, 20).finish();
assert!(out.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
assert!(out.contains("viewBox=\"0 0 10 20\""));
assert!(out.trim_end().ends_with("</svg>"));
assert!(!out.contains("<defs>"), "no clips, no defs block");
}
#[test]
fn an_opaque_fill_carries_no_opacity_attribute() {
let mut svg = Svg::new(4, 4);
svg.fill_path(
&square(),
Affine::IDENTITY,
peniko::Color::from_rgba8(255, 0, 0, 255),
FillRule::Winding,
AntiAlias::On,
);
let out = svg.finish();
assert!(out.contains("fill=\"#ff0000\""));
assert!(!out.contains("fill-opacity"));
assert!(!out.contains("fill-rule"), "nonzero is the default");
assert!(!out.contains("shape-rendering"), "On is the default");
}
#[test]
fn even_odd_and_alpha_and_crisp_edges_all_appear() {
let mut svg = Svg::new(4, 4);
svg.fill_path(
&square(),
Affine::translate((1.0, 2.0)),
peniko::Color::from_rgba8(0, 0, 0, 128),
FillRule::EvenOdd,
AntiAlias::Off,
);
let out = svg.finish();
assert!(out.contains("fill-rule=\"evenodd\""));
assert!(out.contains("fill-opacity=\"0.502\""));
assert!(out.contains("transform=\"matrix(1 0 0 1 1 2)\""));
assert!(out.contains("shape-rendering=\"crispEdges\""));
}
#[test]
fn a_stroke_carries_its_whole_pen() {
let mut svg = Svg::new(4, 4);
let stroke = Stroke::new(2.5)
.with_caps(kurbo::Cap::Round)
.with_join(kurbo::Join::Bevel)
.with_dashes(1.5, [3.0, 2.0]);
svg.stroke_path(
&square(),
Affine::IDENTITY,
peniko::Color::from_rgba8(0, 0, 255, 255),
&stroke,
AntiAlias::On,
);
let out = svg.finish();
assert!(out.contains("stroke=\"#0000ff\""));
assert!(out.contains("stroke-width=\"2.5\""));
assert!(out.contains("stroke-linecap=\"round\""));
assert!(out.contains("stroke-linejoin=\"bevel\""));
assert!(out.contains("stroke-dasharray=\"3 2\""));
assert!(out.contains("stroke-dashoffset=\"1.5\""));
assert!(!out.contains("miterlimit"), "only a miter join needs one");
}
#[test]
fn clips_become_defs_and_groups_and_close_in_order() {
let mut svg = Svg::new(8, 8);
svg.push_clip_rect(Rect::new(0.0, 0.0, 4.0, 8.0));
svg.push_clip(&square(), FillRule::EvenOdd);
svg.pop();
svg.pop();
let out = svg.finish();
assert_eq!(out.matches("<clipPath").count(), 2);
assert_eq!(out.matches("clip-path=\"url(#").count(), 2);
assert_eq!(out.matches("</g>").count(), 2);
assert!(out.contains("clip-rule=\"evenodd\""));
assert!(out.contains("<rect x=\"0\" y=\"0\" width=\"4\" height=\"8\""));
}
#[test]
fn a_layer_carries_opacity_and_blend_mode() {
let mut svg = Svg::new(4, 4);
svg.push_layer(BlendMode::Multiply, 0.5);
svg.pop();
let out = svg.finish();
assert!(out.contains("opacity=\"0.5\""));
assert!(out.contains("mix-blend-mode:multiply"));
}
#[test]
fn a_normal_opaque_layer_is_a_bare_group() {
let mut svg = Svg::new(4, 4);
svg.push_layer(BlendMode::Compatible, 1.0);
svg.pop();
assert!(svg.finish().contains("<g>"));
}
#[test]
fn every_blend_mode_but_the_two_normals_has_a_css_keyword() {
let all = [
BlendMode::Multiply,
BlendMode::Screen,
BlendMode::Overlay,
BlendMode::Darken,
BlendMode::Lighten,
BlendMode::ColorDodge,
BlendMode::ColorBurn,
BlendMode::HardLight,
BlendMode::SoftLight,
BlendMode::Difference,
BlendMode::Exclusion,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Color,
BlendMode::Luminosity,
];
for mode in all {
assert!(Svg::blend_name(mode).is_some(), "{mode:?}");
}
assert_eq!(Svg::blend_name(BlendMode::Normal), None);
assert_eq!(Svg::blend_name(BlendMode::Compatible), None);
}
#[test]
fn an_unbalanced_stack_is_closed_by_finish() {
let mut svg = Svg::new(4, 4);
svg.push_layer(BlendMode::Normal, 1.0);
svg.push_clip_rect(Rect::new(0.0, 0.0, 1.0, 1.0));
let out = svg.finish();
assert_eq!(out.matches("</g>").count(), 2);
assert!(out.trim_end().ends_with("</svg>"));
}
#[test]
fn a_pop_with_nothing_open_is_ignored() {
let mut svg = Svg::new(4, 4);
svg.pop();
assert!(!svg.finish().contains("</g>"));
}
#[test]
fn a_raster_region_is_tagged_with_its_cause() {
let mut svg = Svg::new(4, 4);
svg.draw_raster_region(
b"not-a-real-png",
2,
2,
Affine::translate((1.0, 1.0)),
1.0,
crate::RasterCause::MeshShading,
);
let out = svg.finish();
assert!(out.contains("data-cause=\"mesh-shading\""));
assert!(out.contains("data:image/png;base64,"));
assert!(out.contains("width=\"2\" height=\"2\""));
}
}