use std::path::Path;
use uzor::render::RenderContext;
use uzor_render_svg::SvgRenderContext;
use uzor_render_tiny_skia::TinySkiaCpuRenderContext;
pub mod pdf;
pub use pdf::{
FontId, PdfBuilder, PdfContentStream, PdfDate, PdfFont, PdfFontCache, PdfLink, PdfMeta, PdfOutlineEntry, PdfPageSpec, PdfRenderContext, PdfTagRole,
PdfTextRun, StructElemId,
};
#[derive(Debug, Clone, Copy)]
pub struct ExportSpec {
pub width_px: u32,
pub height_px: u32,
pub dpr: f64,
pub background: Option<[u8; 4]>,
}
#[derive(Debug)]
pub enum ExportError {
ZeroSize,
Backend(String),
Encode(String),
Io(std::io::Error),
RasterDecode(String),
RasterDimensionMismatch {
expected: (u32, u32),
actual: (u32, u32),
},
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExportError::ZeroSize => {
write!(f, "export size must be non-zero (width_px and height_px > 0)")
}
ExportError::Backend(msg) => write!(f, "render backend error: {msg}"),
ExportError::Encode(msg) => write!(f, "PNG encode error: {msg}"),
ExportError::Io(e) => write!(f, "I/O error writing export: {e}"),
ExportError::RasterDecode(msg) => write!(f, "PDF raster background decode error: {msg}"),
ExportError::RasterDimensionMismatch { expected, actual } => write!(
f,
"PDF raster background dimension mismatch: spec declared {}x{}, decoded PNG is {}x{}",
expected.0, expected.1, actual.0, actual.1
),
}
}
}
impl std::error::Error for ExportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ExportError::Io(e) => Some(e),
ExportError::ZeroSize
| ExportError::Backend(_)
| ExportError::Encode(_)
| ExportError::RasterDecode(_)
| ExportError::RasterDimensionMismatch { .. } => None,
}
}
}
impl From<std::io::Error> for ExportError {
fn from(e: std::io::Error) -> Self {
ExportError::Io(e)
}
}
pub fn render_to_png(
spec: &ExportSpec,
draw: impl FnOnce(&mut dyn RenderContext),
) -> Result<Vec<u8>, ExportError> {
if spec.width_px == 0 || spec.height_px == 0 {
return Err(ExportError::ZeroSize);
}
if tiny_skia::Pixmap::new(spec.width_px, spec.height_px).is_none() {
return Err(ExportError::Backend(format!(
"failed to allocate a {}x{} pixmap",
spec.width_px, spec.height_px
)));
}
let mut ctx = TinySkiaCpuRenderContext::new(spec.width_px, spec.height_px, spec.dpr);
let bg = match spec.background {
Some([r, g, b, a]) => tiny_skia::Color::from_rgba8(r, g, b, a),
None => tiny_skia::Color::TRANSPARENT,
};
ctx.clear(bg);
draw(&mut ctx);
ctx.pixmap()
.encode_png()
.map_err(|e| ExportError::Encode(e.to_string()))
}
pub fn render_to_png_file(
path: &Path,
spec: &ExportSpec,
draw: impl FnOnce(&mut dyn RenderContext),
) -> Result<(), ExportError> {
let bytes = render_to_png(spec, draw)?;
std::fs::write(path, bytes)?;
Ok(())
}
pub fn render_to_svg(
spec: &ExportSpec,
draw: impl FnOnce(&mut dyn RenderContext),
) -> Result<String, ExportError> {
if spec.width_px == 0 || spec.height_px == 0 {
return Err(ExportError::ZeroSize);
}
let mut ctx = SvgRenderContext::new(spec.width_px, spec.height_px, spec.dpr);
if let Some([r, g, b, a]) = spec.background {
let ctx_dyn: &mut dyn RenderContext = &mut ctx;
ctx_dyn.set_fill_color(&format!("#{r:02x}{g:02x}{b:02x}{a:02x}"));
ctx_dyn.fill_rect(0.0, 0.0, spec.width_px as f64, spec.height_px as f64);
}
draw(&mut ctx);
Ok(ctx.finish())
}
pub fn render_to_svg_file(
path: &Path,
spec: &ExportSpec,
draw: impl FnOnce(&mut dyn RenderContext),
) -> Result<(), ExportError> {
let svg = render_to_svg(spec, draw)?;
std::fs::write(path, svg)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_red_rect_on_white_background() {
let spec = ExportSpec {
width_px: 200,
height_px: 100,
dpr: 1.0,
background: Some([255, 255, 255, 255]),
};
let bytes = render_to_png(&spec, |ctx| {
ctx.set_fill_color("#ff0000");
ctx.fill_rect(10.0, 10.0, 50.0, 50.0);
})
.expect("render_to_png should succeed");
assert!(
bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]),
"output should start with the PNG magic bytes"
);
let decoder = png::Decoder::new(bytes.as_slice());
let reader = decoder.read_info().expect("valid PNG header");
let info = reader.info();
assert_eq!(info.width, spec.width_px);
assert_eq!(info.height, spec.height_px);
}
#[test]
fn zero_size_is_rejected() {
let spec = ExportSpec {
width_px: 0,
height_px: 0,
dpr: 1.0,
background: None,
};
let err = render_to_png(&spec, |_ctx| {}).expect_err("zero size must error");
assert!(matches!(err, ExportError::ZeroSize));
}
#[test]
fn transparent_background_has_zero_alpha_pixel() {
let spec = ExportSpec {
width_px: 20,
height_px: 20,
dpr: 1.0,
background: None,
};
let bytes = render_to_png(&spec, |_ctx| {
})
.expect("render_to_png should succeed");
let decoder = png::Decoder::new(bytes.as_slice());
let mut reader = decoder.read_info().expect("valid PNG header");
let mut buf = vec![0u8; reader.output_buffer_size()];
reader.next_frame(&mut buf).expect("decode frame");
assert_eq!(
buf[3], 0,
"pixel (0,0) alpha should be 0 for a transparent background"
);
}
#[test]
fn renders_red_rect_on_white_background_svg() {
let spec = ExportSpec {
width_px: 200,
height_px: 100,
dpr: 1.0,
background: Some([255, 255, 255, 255]),
};
let svg = render_to_svg(&spec, |ctx| {
ctx.set_fill_color("#ff0000");
ctx.fill_rect(10.0, 10.0, 50.0, 50.0);
})
.expect("render_to_svg should succeed");
assert!(svg.starts_with("<svg"), "output should start with the <svg root element");
assert!(svg.contains("width=\"200\""));
assert!(svg.contains("height=\"100\""));
assert!(svg.contains("fill=\"#ff0000\""));
}
#[test]
fn svg_zero_size_is_rejected() {
let spec = ExportSpec {
width_px: 0,
height_px: 0,
dpr: 1.0,
background: None,
};
let err = render_to_svg(&spec, |_ctx| {}).expect_err("zero size must error");
assert!(matches!(err, ExportError::ZeroSize));
}
#[test]
fn svg_background_paints_a_rect_before_the_draw_closure() {
let spec = ExportSpec {
width_px: 20,
height_px: 20,
dpr: 1.0,
background: Some([10, 20, 30, 255]),
};
let svg = render_to_svg(&spec, |_ctx| {}).expect("render_to_svg should succeed");
assert!(
svg.contains("fill=\"#0a141e\""),
"expected the background color as a fill, got: {svg}"
);
}
}