use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use crate::content::{ContentBuilder, LineCap, LineJoin};
use crate::document::PdfContext;
use crate::error::{Error, Result};
use crate::objects::{PdfArray, PdfDict, PdfName, PdfObject, PdfRef};
use usvg::fontdb;
use usvg::tiny_skia_path::PathSegment;
use usvg::{FillRule, Node, Paint, Path, Transform, Tree};
fn sanitize_font_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '+' {
c
} else {
'_'
}
})
.collect()
}
#[derive(Debug, Clone)]
pub struct SvgOptions {
pub fonts: Vec<Vec<u8>>,
pub load_system_fonts: bool,
}
impl Default for SvgOptions {
fn default() -> Self {
Self {
fonts: Vec::new(),
load_system_fonts: true, }
}
}
impl SvgOptions {
pub fn new() -> Self {
Self::default()
}
pub fn font(mut self, data: Vec<u8>) -> Self {
self.fonts.push(data);
self
}
pub fn fonts(mut self, fonts: Vec<Vec<u8>>) -> Self {
self.fonts.extend(fonts);
self
}
pub fn no_system_fonts(mut self) -> Self {
self.load_system_fonts = false;
self
}
}
#[derive(Debug, Clone)]
pub struct SvgFontData {
pub pdf_name: String,
pub data: Arc<Vec<u8>>,
pub face_index: u32,
pub units_per_em: u16,
pub glyph_set: BTreeMap<u16, String>,
}
#[derive(Debug, Default)]
pub struct SvgResources {
pub shadings: Vec<(String, PdfRef)>,
pub ext_gstates: Vec<(String, PdfRef)>,
pub fonts: Vec<SvgFontData>,
}
pub struct SvgRenderer<'a> {
content: &'a mut ContentBuilder,
context: &'a mut PdfContext,
resources: SvgResources,
gradient_cache: HashMap<String, String>,
opacity_cache: HashMap<String, String>,
font_cache: HashMap<fontdb::ID, String>,
#[allow(clippy::type_complexity)]
font_data_cache: HashMap<fontdb::ID, (Arc<Vec<u8>>, u32, u16, String)>,
font_glyphs: HashMap<fontdb::ID, BTreeMap<u16, String>>,
}
impl<'a> SvgRenderer<'a> {
pub fn new(content: &'a mut ContentBuilder, context: &'a mut PdfContext) -> Self {
SvgRenderer {
content,
context,
resources: SvgResources::default(),
gradient_cache: HashMap::new(),
opacity_cache: HashMap::new(),
font_cache: HashMap::new(),
font_data_cache: HashMap::new(),
font_glyphs: HashMap::new(),
}
}
pub fn render(
self,
svg: &str,
position: [f64; 2],
width: f64,
height: f64,
) -> Result<SvgResources> {
self.render_with_options(svg, position, width, height, &SvgOptions::default())
}
pub fn render_with_options(
mut self,
svg: &str,
position: [f64; 2],
width: f64,
height: f64,
svg_options: &SvgOptions,
) -> Result<SvgResources> {
if width <= 0.0 || height <= 0.0 {
return Err(Error::Svg("SVG target size must be positive".to_string()));
}
let mut options = usvg::Options::default();
for font_data in &svg_options.fonts {
options.fontdb_mut().load_font_data(font_data.clone());
}
if svg_options.load_system_fonts {
options.fontdb_mut().load_system_fonts();
}
let tree = Tree::from_str(svg, &options)
.map_err(|e| Error::Svg(format!("Failed to parse SVG: {e}")))?;
let size = tree.size();
let svg_width = size.width() as f64;
let svg_height = size.height() as f64;
if svg_width <= 0.0 || svg_height <= 0.0 {
return Err(Error::Svg("SVG has invalid size".to_string()));
}
let scale_x = width / svg_width;
let scale_y = height / svg_height;
let base_transform = Transform::from_row(
scale_x as f32,
0.0,
0.0,
-(scale_y as f32),
position[0] as f32,
(position[1] + height) as f32,
);
#[cfg(feature = "fonts")]
self.collect_fonts_from_tree(tree.root(), tree.fontdb());
self.render_group(tree.root(), base_transform, tree.fontdb())?;
#[cfg(feature = "fonts")]
for (font_id, pdf_name) in &self.font_cache {
if let Some((data, face_index, units_per_em, _)) = self.font_data_cache.get(font_id) {
let glyph_set = self.font_glyphs.get(font_id).cloned().unwrap_or_default();
self.resources.fonts.push(SvgFontData {
pdf_name: pdf_name.clone(),
data: data.clone(),
face_index: *face_index,
units_per_em: *units_per_em,
glyph_set,
});
}
}
Ok(self.resources)
}
#[cfg(feature = "fonts")]
fn collect_fonts_from_tree(&mut self, group: &usvg::Group, fontdb: &fontdb::Database) {
for node in group.children() {
match node {
Node::Group(g) => self.collect_fonts_from_tree(g, fontdb),
Node::Text(text) => {
for span in text.layouted() {
for glyph in &span.positioned_glyphs {
let font_id = glyph.font;
if let std::collections::hash_map::Entry::Vacant(e) =
self.font_data_cache.entry(font_id)
{
fontdb.with_face_data(font_id, |data, face_index| {
if let Ok(face) = ttf_parser::Face::parse(data, face_index) {
let units_per_em = face.units_per_em();
let font_key = face
.names()
.into_iter()
.find(|n| {
n.name_id == ttf_parser::name_id::POST_SCRIPT_NAME
})
.and_then(|n| n.to_string())
.unwrap_or_else(|| {
format!("Font{:08X}", {
let mut h: u32 = 0;
for (i, &b) in
data.iter().take(1000).enumerate()
{
h = h.wrapping_add(
(b as u32).wrapping_mul((i + 1) as u32),
);
}
h
})
});
e.insert((
Arc::new(data.to_vec()),
face_index,
units_per_em,
font_key,
));
}
});
}
if !self.font_cache.contains_key(&font_id) {
if let Some((_, _, _, font_key)) =
self.font_data_cache.get(&font_id)
{
let pdf_name = format!("SVG_{}", sanitize_font_name(font_key));
self.font_cache.insert(font_id, pdf_name);
}
}
self.font_glyphs
.entry(font_id)
.or_default()
.insert(glyph.id.0, glyph.text.clone());
}
}
}
_ => {}
}
}
}
fn next_name(&mut self, prefix: &str) -> String {
self.context.next_svg_resource_name(prefix)
}
#[allow(clippy::only_used_in_recursion)]
fn render_group(
&mut self,
group: &usvg::Group,
base: Transform,
fontdb: &fontdb::Database,
) -> Result<()> {
let opacity = group.opacity().get();
let has_opacity = opacity < 1.0;
let has_clip = group.clip_path().is_some();
if let Some(clip_path) = group.clip_path() {
self.content.save_state();
self.apply_clip_path(clip_path, base)?;
}
if has_opacity {
self.content.save_state();
self.apply_opacity(opacity as f64)?;
}
for node in group.children() {
match node {
Node::Group(g) => self.render_group(g, base, fontdb)?,
Node::Path(path) => self.render_path(path, base)?,
Node::Text(text) => {
#[cfg(feature = "fonts")]
{
self.render_text(text, base)?;
}
#[cfg(not(feature = "fonts"))]
{
log::warn!(
"SVG text element skipped: 'fonts' feature required for text rendering"
);
let _ = text; }
}
Node::Image(_) => {
}
}
}
if has_opacity {
self.content.restore_state();
}
if has_clip {
self.content.restore_state();
}
Ok(())
}
fn apply_clip_path(&mut self, clip: &usvg::ClipPath, base: Transform) -> Result<()> {
let clip_transform = base.pre_concat(clip.transform());
for node in clip.root().children() {
if let Node::Path(path) = node {
let path_transform = clip_transform.pre_concat(path.abs_transform());
draw_path_segments_transformed(
path.data().segments(),
self.content,
path_transform,
);
}
}
self.content.line("W n");
Ok(())
}
fn apply_opacity(&mut self, opacity: f64) -> Result<()> {
let key = format!("{:.3}", opacity);
if let Some(name) = self.opacity_cache.get(&key) {
self.content.set_graphics_state(name);
return Ok(());
}
let gs_ref = self.context.alloc_ref();
let gs_name = self.next_name("sGS");
let mut gs_dict = PdfDict::new();
gs_dict.set("Type", PdfObject::Name(PdfName::new("ExtGState")));
gs_dict.set("CA", PdfObject::Real(opacity)); gs_dict.set("ca", PdfObject::Real(opacity));
self.context.assign(gs_ref, PdfObject::Dict(gs_dict));
self.resources.ext_gstates.push((gs_name.clone(), gs_ref));
self.opacity_cache.insert(key, gs_name.clone());
self.content.set_graphics_state(&gs_name);
Ok(())
}
#[cfg(feature = "fonts")]
fn render_text(&mut self, text: &usvg::Text, base: Transform) -> Result<()> {
for span in text.layouted() {
if !span.visible {
continue;
}
let (r, g, b) = if let Some(fill) = &span.fill {
match fill.paint() {
Paint::Color(color) => (
color.red as f64 / 255.0,
color.green as f64 / 255.0,
color.blue as f64 / 255.0,
),
_ => (0.0, 0.0, 0.0),
}
} else {
(0.0, 0.0, 0.0)
};
let opacity = span.fill.as_ref().map(|f| f.opacity().get()).unwrap_or(1.0);
let has_opacity = opacity < 1.0;
if has_opacity {
self.content.save_state();
self.apply_opacity(opacity as f64)?;
}
for glyph in &span.positioned_glyphs {
let pdf_font_name = match self.font_cache.get(&glyph.font) {
Some(name) => name.clone(),
None => continue, };
let units_per_em = self
.font_data_cache
.get(&glyph.font)
.map(|(_, _, u, _)| *u)
.unwrap_or(1000) as f32;
let glyph_transform = glyph
.outline_transform()
.pre_scale(units_per_em, units_per_em)
.pre_scale(1.0 / span.font_size.get(), 1.0 / span.font_size.get());
let combined = base.pre_concat(glyph_transform);
self.content.save_state();
self.content.set_fill_color_rgb(r, g, b);
self.content.begin_text();
self.content.set_text_matrix(
combined.sx as f64,
combined.ky as f64,
combined.kx as f64,
combined.sy as f64,
combined.tx as f64,
combined.ty as f64,
);
self.content
.set_font(&pdf_font_name, span.font_size.get() as f64);
let hex = format!("{:04X}", glyph.id.0);
self.content.show_text_hex(&hex);
self.content.end_text();
self.content.restore_state();
}
if has_opacity {
self.content.restore_state();
}
}
Ok(())
}
fn render_path(&mut self, path: &Path, base: Transform) -> Result<()> {
if !path.is_visible() {
return Ok(());
}
let transform = base.pre_concat(path.abs_transform());
match path.paint_order() {
usvg::PaintOrder::FillAndStroke => {
self.render_fill(path, transform)?;
self.render_stroke(path, transform)?;
}
usvg::PaintOrder::StrokeAndFill => {
self.render_stroke(path, transform)?;
self.render_fill(path, transform)?;
}
}
Ok(())
}
fn render_fill(&mut self, path: &Path, transform: Transform) -> Result<()> {
let fill = match path.fill() {
Some(fill) => fill,
None => return Ok(()),
};
let opacity = fill.opacity().get();
let has_opacity = opacity < 1.0;
if has_opacity {
self.content.save_state();
self.apply_opacity(opacity as f64)?;
}
self.content.save_state();
apply_transform(self.content, transform);
match fill.paint() {
Paint::Color(color) => {
self.content.set_fill_color_rgb(
color.red as f64 / 255.0,
color.green as f64 / 255.0,
color.blue as f64 / 255.0,
);
draw_path_segments(path.data().segments(), self.content);
match fill.rule() {
FillRule::EvenOdd => self.content.fill_even_odd(),
FillRule::NonZero => self.content.fill(),
};
}
Paint::LinearGradient(grad) => {
self.fill_with_linear_gradient(path, grad, fill.rule())?;
}
Paint::RadialGradient(grad) => {
self.fill_with_radial_gradient(path, grad, fill.rule())?;
}
Paint::Pattern(_) => {
}
}
self.content.restore_state();
if has_opacity {
self.content.restore_state();
}
Ok(())
}
fn render_stroke(&mut self, path: &Path, transform: Transform) -> Result<()> {
let stroke = match path.stroke() {
Some(stroke) => stroke,
None => return Ok(()),
};
let opacity = stroke.opacity().get();
let has_opacity = opacity < 1.0;
if has_opacity {
self.content.save_state();
self.apply_opacity(opacity as f64)?;
}
self.content.save_state();
apply_transform(self.content, transform);
self.content.set_line_width(stroke.width().get() as f64);
self.content.set_line_cap(map_line_cap(stroke.linecap()));
self.content.set_line_join(map_line_join(stroke.linejoin()));
if let Some(pattern) = stroke.dasharray() {
let pattern: Vec<f64> = pattern.iter().map(|value| *value as f64).collect();
self.content.set_dash(&pattern, stroke.dashoffset() as f64);
}
match stroke.paint() {
Paint::Color(color) => {
self.content.set_stroke_color_rgb(
color.red as f64 / 255.0,
color.green as f64 / 255.0,
color.blue as f64 / 255.0,
);
draw_path_segments(path.data().segments(), self.content);
self.content.stroke();
}
Paint::LinearGradient(_) | Paint::RadialGradient(_) => {
if let Paint::LinearGradient(grad) = stroke.paint() {
if let Some(stop) = grad.stops().first() {
self.content.set_stroke_color_rgb(
stop.color().red as f64 / 255.0,
stop.color().green as f64 / 255.0,
stop.color().blue as f64 / 255.0,
);
}
} else if let Paint::RadialGradient(grad) = stroke.paint() {
if let Some(stop) = grad.stops().first() {
self.content.set_stroke_color_rgb(
stop.color().red as f64 / 255.0,
stop.color().green as f64 / 255.0,
stop.color().blue as f64 / 255.0,
);
}
}
draw_path_segments(path.data().segments(), self.content);
self.content.stroke();
}
Paint::Pattern(_) => {
}
}
self.content.restore_state();
if has_opacity {
self.content.restore_state();
}
Ok(())
}
fn fill_with_linear_gradient(
&mut self,
path: &Path,
grad: &usvg::LinearGradient,
fill_rule: FillRule,
) -> Result<()> {
let shading_name = self.create_linear_gradient_shading(grad)?;
draw_path_segments(path.data().segments(), self.content);
match fill_rule {
FillRule::EvenOdd => self.content.line("W* n"),
FillRule::NonZero => self.content.line("W n"),
};
let grad_transform = grad.transform();
self.content.save_state();
self.content.concat_matrix(
grad_transform.sx as f64,
grad_transform.ky as f64,
grad_transform.kx as f64,
grad_transform.sy as f64,
grad_transform.tx as f64,
grad_transform.ty as f64,
);
self.content.line(&format!("/{} sh", shading_name));
self.content.restore_state();
Ok(())
}
fn create_linear_gradient_shading(&mut self, grad: &usvg::LinearGradient) -> Result<String> {
let grad_id = grad.id().to_string();
if let Some(name) = self.gradient_cache.get(&grad_id) {
return Ok(name.clone());
}
let shading_ref = self.context.alloc_ref();
let shading_name = self.next_name("sSh");
let function = self.create_gradient_function(grad.stops())?;
let func_ref = self.context.register(PdfObject::Dict(function));
let mut shading = PdfDict::new();
shading.set("ShadingType", PdfObject::Integer(2)); shading.set("ColorSpace", PdfObject::Name(PdfName::new("DeviceRGB")));
shading.set(
"Coords",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(grad.x1() as f64),
PdfObject::Real(grad.y1() as f64),
PdfObject::Real(grad.x2() as f64),
PdfObject::Real(grad.y2() as f64),
])),
);
shading.set("Function", PdfObject::Reference(func_ref));
match grad.spread_method() {
usvg::SpreadMethod::Pad => {
shading.set(
"Extend",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Bool(true),
PdfObject::Bool(true),
])),
);
}
_ => {
shading.set(
"Extend",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Bool(true),
PdfObject::Bool(true),
])),
);
}
}
self.context.assign(shading_ref, PdfObject::Dict(shading));
self.resources
.shadings
.push((shading_name.clone(), shading_ref));
self.gradient_cache.insert(grad_id, shading_name.clone());
Ok(shading_name)
}
fn fill_with_radial_gradient(
&mut self,
path: &Path,
grad: &usvg::RadialGradient,
fill_rule: FillRule,
) -> Result<()> {
let shading_name = self.create_radial_gradient_shading(grad)?;
draw_path_segments(path.data().segments(), self.content);
match fill_rule {
FillRule::EvenOdd => self.content.line("W* n"),
FillRule::NonZero => self.content.line("W n"),
};
let grad_transform = grad.transform();
self.content.save_state();
self.content.concat_matrix(
grad_transform.sx as f64,
grad_transform.ky as f64,
grad_transform.kx as f64,
grad_transform.sy as f64,
grad_transform.tx as f64,
grad_transform.ty as f64,
);
self.content.line(&format!("/{} sh", shading_name));
self.content.restore_state();
Ok(())
}
fn create_radial_gradient_shading(&mut self, grad: &usvg::RadialGradient) -> Result<String> {
let grad_id = grad.id().to_string();
if let Some(name) = self.gradient_cache.get(&grad_id) {
return Ok(name.clone());
}
let shading_ref = self.context.alloc_ref();
let shading_name = self.next_name("sSh");
let function = self.create_gradient_function(grad.stops())?;
let func_ref = self.context.register(PdfObject::Dict(function));
let mut shading = PdfDict::new();
shading.set("ShadingType", PdfObject::Integer(3)); shading.set("ColorSpace", PdfObject::Name(PdfName::new("DeviceRGB")));
shading.set(
"Coords",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(grad.fx() as f64), PdfObject::Real(grad.fy() as f64),
PdfObject::Real(0.0), PdfObject::Real(grad.cx() as f64), PdfObject::Real(grad.cy() as f64),
PdfObject::Real(grad.r().get() as f64), ])),
);
shading.set("Function", PdfObject::Reference(func_ref));
shading.set(
"Extend",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Bool(true),
PdfObject::Bool(true),
])),
);
self.context.assign(shading_ref, PdfObject::Dict(shading));
self.resources
.shadings
.push((shading_name.clone(), shading_ref));
self.gradient_cache.insert(grad_id, shading_name.clone());
Ok(shading_name)
}
fn create_gradient_function(&self, stops: &[usvg::Stop]) -> Result<PdfDict> {
if stops.len() < 2 {
return Err(Error::Svg(
"Gradient must have at least 2 stops".to_string(),
));
}
if stops.len() == 2 {
let mut func = PdfDict::new();
func.set("FunctionType", PdfObject::Integer(2));
func.set(
"Domain",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(0.0),
PdfObject::Real(1.0),
])),
);
func.set("N", PdfObject::Real(1.0));
let c0 = &stops[0];
let c1 = &stops[1];
func.set(
"C0",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(c0.color().red as f64 / 255.0),
PdfObject::Real(c0.color().green as f64 / 255.0),
PdfObject::Real(c0.color().blue as f64 / 255.0),
])),
);
func.set(
"C1",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(c1.color().red as f64 / 255.0),
PdfObject::Real(c1.color().green as f64 / 255.0),
PdfObject::Real(c1.color().blue as f64 / 255.0),
])),
);
Ok(func)
} else {
let mut func = PdfDict::new();
func.set("FunctionType", PdfObject::Integer(3));
func.set(
"Domain",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(0.0),
PdfObject::Real(1.0),
])),
);
let mut functions = PdfArray::new();
let mut bounds = PdfArray::new();
let mut encode = PdfArray::new();
for i in 0..stops.len() - 1 {
let c0 = &stops[i];
let c1 = &stops[i + 1];
let mut subfunc = PdfDict::new();
subfunc.set("FunctionType", PdfObject::Integer(2));
subfunc.set(
"Domain",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(0.0),
PdfObject::Real(1.0),
])),
);
subfunc.set("N", PdfObject::Real(1.0));
subfunc.set(
"C0",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(c0.color().red as f64 / 255.0),
PdfObject::Real(c0.color().green as f64 / 255.0),
PdfObject::Real(c0.color().blue as f64 / 255.0),
])),
);
subfunc.set(
"C1",
PdfObject::Array(PdfArray::from(vec![
PdfObject::Real(c1.color().red as f64 / 255.0),
PdfObject::Real(c1.color().green as f64 / 255.0),
PdfObject::Real(c1.color().blue as f64 / 255.0),
])),
);
functions.push(PdfObject::Dict(subfunc));
if i < stops.len() - 2 {
bounds.push(PdfObject::Real(c1.offset().get() as f64));
}
encode.push(PdfObject::Real(0.0));
encode.push(PdfObject::Real(1.0));
}
func.set("Functions", PdfObject::Array(functions));
func.set("Bounds", PdfObject::Array(bounds));
func.set("Encode", PdfObject::Array(encode));
Ok(func)
}
}
}
pub fn render_svg(
content: &mut ContentBuilder,
context: &mut PdfContext,
svg: &str,
position: [f64; 2],
width: f64,
height: f64,
) -> Result<SvgResources> {
let renderer = SvgRenderer::new(content, context);
renderer.render(svg, position, width, height)
}
pub fn render_svg_with_options(
content: &mut ContentBuilder,
context: &mut PdfContext,
svg: &str,
position: [f64; 2],
width: f64,
height: f64,
options: &SvgOptions,
) -> Result<SvgResources> {
let renderer = SvgRenderer::new(content, context);
renderer.render_with_options(svg, position, width, height, options)
}
pub fn render_svg_paths(
content: &mut ContentBuilder,
svg: &str,
position: [f64; 2],
width: f64,
height: f64,
) -> Result<()> {
if width <= 0.0 || height <= 0.0 {
return Err(Error::Svg("SVG target size must be positive".to_string()));
}
let options = usvg::Options::default();
let tree = Tree::from_str(svg, &options)
.map_err(|e| Error::Svg(format!("Failed to parse SVG: {e}")))?;
let size = tree.size();
let svg_width = size.width() as f64;
let svg_height = size.height() as f64;
if svg_width <= 0.0 || svg_height <= 0.0 {
return Err(Error::Svg("SVG has invalid size".to_string()));
}
let scale_x = width / svg_width;
let scale_y = height / svg_height;
let base_transform = Transform::from_row(
scale_x as f32,
0.0,
0.0,
-(scale_y as f32),
position[0] as f32,
(position[1] + height) as f32,
);
render_group_legacy(tree.root(), content, base_transform)
}
fn apply_transform(content: &mut ContentBuilder, transform: Transform) {
content.concat_matrix(
transform.sx as f64,
transform.ky as f64,
transform.kx as f64,
transform.sy as f64,
transform.tx as f64,
transform.ty as f64,
);
}
fn draw_path_segments(segments: impl Iterator<Item = PathSegment>, content: &mut ContentBuilder) {
fn calc(n1: f32, n2: f32) -> f32 {
(n1 + n2 * 2.0) / 3.0
}
let mut prev = None;
for segment in segments {
match segment {
PathSegment::MoveTo(p) => {
content.move_to(p.x as f64, p.y as f64);
prev = Some(p);
}
PathSegment::LineTo(p) => {
content.line_to(p.x as f64, p.y as f64);
prev = Some(p);
}
PathSegment::QuadTo(p1, p2) => {
if let Some(prev) = prev {
content.curve_to(
calc(prev.x, p1.x) as f64,
calc(prev.y, p1.y) as f64,
calc(p2.x, p1.x) as f64,
calc(p2.y, p1.y) as f64,
p2.x as f64,
p2.y as f64,
);
}
prev = Some(p2);
}
PathSegment::CubicTo(p1, p2, p3) => {
content.curve_to(
p1.x as f64,
p1.y as f64,
p2.x as f64,
p2.y as f64,
p3.x as f64,
p3.y as f64,
);
prev = Some(p3);
}
PathSegment::Close => {
content.close_path();
}
}
}
}
fn draw_path_segments_transformed(
segments: impl Iterator<Item = PathSegment>,
content: &mut ContentBuilder,
transform: Transform,
) {
fn calc(n1: f32, n2: f32) -> f32 {
(n1 + n2 * 2.0) / 3.0
}
let tx = |x: f32, y: f32| -> (f64, f64) {
let new_x = transform.sx * x + transform.kx * y + transform.tx;
let new_y = transform.ky * x + transform.sy * y + transform.ty;
(new_x as f64, new_y as f64)
};
let mut prev = None;
for segment in segments {
match segment {
PathSegment::MoveTo(p) => {
let (x, y) = tx(p.x, p.y);
content.move_to(x, y);
prev = Some(p);
}
PathSegment::LineTo(p) => {
let (x, y) = tx(p.x, p.y);
content.line_to(x, y);
prev = Some(p);
}
PathSegment::QuadTo(p1, p2) => {
if let Some(prev_pt) = prev {
let (x1, y1) = tx(calc(prev_pt.x, p1.x), calc(prev_pt.y, p1.y));
let (x2, y2) = tx(calc(p2.x, p1.x), calc(p2.y, p1.y));
let (x3, y3) = tx(p2.x, p2.y);
content.curve_to(x1, y1, x2, y2, x3, y3);
}
prev = Some(p2);
}
PathSegment::CubicTo(p1, p2, p3) => {
let (x1, y1) = tx(p1.x, p1.y);
let (x2, y2) = tx(p2.x, p2.y);
let (x3, y3) = tx(p3.x, p3.y);
content.curve_to(x1, y1, x2, y2, x3, y3);
prev = Some(p3);
}
PathSegment::Close => {
content.close_path();
}
}
}
}
fn map_line_cap(cap: usvg::LineCap) -> LineCap {
match cap {
usvg::LineCap::Butt => LineCap::Butt,
usvg::LineCap::Round => LineCap::Round,
usvg::LineCap::Square => LineCap::Square,
}
}
fn map_line_join(join: usvg::LineJoin) -> LineJoin {
match join {
usvg::LineJoin::Round => LineJoin::Round,
usvg::LineJoin::Bevel => LineJoin::Bevel,
usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter,
}
}
fn render_group_legacy(
group: &usvg::Group,
content: &mut ContentBuilder,
base: Transform,
) -> Result<()> {
for node in group.children() {
match node {
Node::Group(group) => render_group_legacy(group, content, base)?,
Node::Path(path) => render_path_legacy(path, content, base)?,
Node::Text(text) => {
render_group_legacy(text.flattened(), content, base)?;
}
_ => {}
}
}
Ok(())
}
fn render_path_legacy(path: &Path, content: &mut ContentBuilder, base: Transform) -> Result<()> {
if !path.is_visible() {
return Ok(());
}
let transform = base.pre_concat(path.abs_transform());
match path.paint_order() {
usvg::PaintOrder::FillAndStroke => {
render_fill_legacy(path, content, transform)?;
render_stroke_legacy(path, content, transform)?;
}
usvg::PaintOrder::StrokeAndFill => {
render_stroke_legacy(path, content, transform)?;
render_fill_legacy(path, content, transform)?;
}
}
Ok(())
}
fn render_fill_legacy(
path: &Path,
content: &mut ContentBuilder,
transform: Transform,
) -> Result<()> {
let fill = match path.fill() {
Some(fill) => fill,
None => return Ok(()),
};
let color = match fill.paint() {
Paint::Color(color) => color,
_ => return Ok(()), };
content.save_state();
apply_transform(content, transform);
content.set_fill_color_rgb(
color.red as f64 / 255.0,
color.green as f64 / 255.0,
color.blue as f64 / 255.0,
);
draw_path_segments(path.data().segments(), content);
match fill.rule() {
FillRule::EvenOdd => content.fill_even_odd(),
FillRule::NonZero => content.fill(),
};
content.restore_state();
Ok(())
}
fn render_stroke_legacy(
path: &Path,
content: &mut ContentBuilder,
transform: Transform,
) -> Result<()> {
let stroke = match path.stroke() {
Some(stroke) => stroke,
None => return Ok(()),
};
let color = match stroke.paint() {
Paint::Color(color) => color,
_ => return Ok(()), };
content.save_state();
apply_transform(content, transform);
content.set_stroke_color_rgb(
color.red as f64 / 255.0,
color.green as f64 / 255.0,
color.blue as f64 / 255.0,
);
content.set_line_width(stroke.width().get() as f64);
content.set_line_cap(map_line_cap(stroke.linecap()));
content.set_line_join(map_line_join(stroke.linejoin()));
if let Some(pattern) = stroke.dasharray() {
let pattern: Vec<f64> = pattern.iter().map(|value| *value as f64).collect();
content.set_dash(&pattern, stroke.dashoffset() as f64);
}
draw_path_segments(path.data().segments(), content);
content.stroke();
content.restore_state();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content::ContentBuilder;
use crate::document::PdfContext;
fn render_test_svg(svg: &str) -> Result<SvgResources> {
let mut content = ContentBuilder::new();
let mut context = PdfContext::new();
let renderer = SvgRenderer::new(&mut content, &mut context);
renderer.render(svg, [0.0, 0.0], 100.0, 100.0)
}
#[test]
fn test_basic_rect() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="10" y="10" width="80" height="80" fill="red"/>
</svg>"#;
let result = render_test_svg(svg);
assert!(result.is_ok());
}
#[test]
fn test_linear_gradient() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ff0000"/>
<stop offset="100%" style="stop-color:#0000ff"/>
</linearGradient>
</defs>
<rect x="10" y="10" width="80" height="80" fill="url(#grad1)"/>
</svg>"##;
let result = render_test_svg(svg).unwrap();
assert!(
!result.shadings.is_empty(),
"Expected shading resource for gradient"
);
assert!(
result.shadings[0].0.starts_with("sSh"),
"Expected sSh prefix"
);
}
#[test]
fn test_radial_gradient() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<radialGradient id="grad1" cx="50%" cy="50%" r="50%">
<stop offset="0%" style="stop-color:#ff0000"/>
<stop offset="100%" style="stop-color:#0000ff"/>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="40" fill="url(#grad1)"/>
</svg>"##;
let result = render_test_svg(svg).unwrap();
assert!(
!result.shadings.is_empty(),
"Expected shading resource for radial gradient"
);
}
#[test]
fn test_opacity() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="10" y="10" width="80" height="80" fill="red" opacity="0.5"/>
</svg>"#;
let result = render_test_svg(svg).unwrap();
assert!(
!result.ext_gstates.is_empty(),
"Expected ExtGState for opacity"
);
assert!(
result.ext_gstates[0].0.starts_with("sGS"),
"Expected sGS prefix"
);
}
#[test]
fn test_group_opacity() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<g opacity="0.7">
<rect x="10" y="10" width="40" height="40" fill="red"/>
<rect x="50" y="50" width="40" height="40" fill="blue"/>
</g>
</svg>"#;
let result = render_test_svg(svg).unwrap();
assert!(
!result.ext_gstates.is_empty(),
"Expected ExtGState for group opacity"
);
}
#[test]
fn test_clip_path() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<clipPath id="clip1">
<circle cx="50" cy="50" r="40"/>
</clipPath>
</defs>
<rect x="0" y="0" width="100" height="100" fill="red" clip-path="url(#clip1)"/>
</svg>"##;
let result = render_test_svg(svg);
assert!(result.is_ok(), "Clip path should render without error");
}
#[test]
fn test_resource_name_uniqueness() {
let mut content = ContentBuilder::new();
let mut context = PdfContext::new();
let svg_with_opacity = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="10" y="10" width="80" height="80" fill="red" opacity="0.5"/>
</svg>"#;
let renderer1 = SvgRenderer::new(&mut content, &mut context);
let result1 = renderer1
.render(svg_with_opacity, [0.0, 0.0], 100.0, 100.0)
.unwrap();
let renderer2 = SvgRenderer::new(&mut content, &mut context);
let result2 = renderer2
.render(svg_with_opacity, [100.0, 0.0], 100.0, 100.0)
.unwrap();
if !result1.ext_gstates.is_empty() && !result2.ext_gstates.is_empty() {
assert_ne!(
result1.ext_gstates[0].0, result2.ext_gstates[0].0,
"Resource names should be unique across draw_svg calls"
);
}
}
#[test]
fn test_svg_options_default() {
let options = SvgOptions::default();
assert!(
options.load_system_fonts,
"System fonts should be enabled by default"
);
assert!(options.fonts.is_empty(), "No custom fonts by default");
}
#[test]
fn test_svg_options_no_system_fonts() {
let options = SvgOptions::new().no_system_fonts();
assert!(
!options.load_system_fonts,
"System fonts should be disabled"
);
}
#[test]
fn test_svg_options_custom_font() {
let fake_font_data = vec![0u8; 100]; let options = SvgOptions::new().font(fake_font_data.clone());
assert_eq!(options.fonts.len(), 1, "Should have one custom font");
assert_eq!(options.fonts[0], fake_font_data);
}
#[test]
fn test_sanitize_font_name() {
assert_eq!(sanitize_font_name("Helvetica"), "Helvetica");
assert_eq!(sanitize_font_name("Arial Bold"), "Arial_Bold");
assert_eq!(sanitize_font_name("Font/Name"), "Font_Name");
assert_eq!(sanitize_font_name("Test-Font_123"), "Test-Font_123");
}
#[test]
fn test_stroke_styles() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<line x1="10" y1="10" x2="90" y2="90" stroke="black" stroke-width="2" stroke-dasharray="5,3"/>
</svg>"#;
let result = render_test_svg(svg);
assert!(result.is_ok(), "Stroke with dash array should render");
}
#[test]
fn test_nested_groups() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<g>
<g>
<rect x="10" y="10" width="80" height="80" fill="red"/>
</g>
</g>
</svg>"#;
let result = render_test_svg(svg);
assert!(result.is_ok(), "Nested groups should render");
}
#[test]
fn test_transforms() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="0" y="0" width="50" height="50" fill="red" transform="translate(25, 25) rotate(45)"/>
</svg>"#;
let result = render_test_svg(svg);
assert!(result.is_ok(), "Transforms should render");
}
#[test]
fn test_invalid_svg() {
let svg = "not valid svg";
let result = render_test_svg(svg);
assert!(result.is_err(), "Invalid SVG should return error");
}
#[test]
fn test_empty_svg() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"></svg>"#;
let result = render_test_svg(svg);
assert!(result.is_ok(), "Empty SVG should render without error");
}
#[test]
fn test_zero_size_rejected() {
let mut content = ContentBuilder::new();
let mut context = PdfContext::new();
let renderer = SvgRenderer::new(&mut content, &mut context);
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"></svg>"#;
let result = renderer.render(svg, [0.0, 0.0], 0.0, 100.0);
assert!(result.is_err(), "Zero width should be rejected");
}
#[test]
fn test_multiple_gradients_same_svg() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ff0000"/>
<stop offset="100%" style="stop-color:#00ff00"/>
</linearGradient>
<linearGradient id="grad2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#0000ff"/>
<stop offset="100%" style="stop-color:#ffff00"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="100" height="100" fill="url(#grad1)"/>
<rect x="100" y="0" width="100" height="100" fill="url(#grad2)"/>
</svg>"##;
let result = render_test_svg(svg).unwrap();
assert_eq!(
result.shadings.len(),
2,
"Should have two shading resources"
);
}
}