use resvg::tiny_skia::{Pixmap, Transform};
use resvg::usvg::{Options, Tree};
use tdsl_core::ir::TimelineIr;
use thiserror::Error;
use crate::layout::{LayoutModel, RenderOptions};
use crate::svg;
#[derive(Debug, Error)]
pub enum PngError {
#[error("SVG formatting failed: {0}")]
Fmt(#[from] std::fmt::Error),
#[error("failed to parse intermediate SVG: {0}")]
Parse(#[from] resvg::usvg::Error),
#[error("failed to allocate pixmap of size {width}x{height}")]
PixmapAlloc { width: u32, height: u32 },
#[error("failed to encode PNG: {0}")]
Encode(String),
}
#[derive(Debug, Clone)]
pub struct PngOptions {
pub dpi: u32,
pub scale_factor: Option<f64>,
}
impl Default for PngOptions {
fn default() -> Self {
Self {
dpi: 96,
scale_factor: None,
}
}
}
impl PngOptions {
fn pixel_scale(&self) -> f64 {
if let Some(sf) = self.scale_factor {
sf
} else {
self.dpi as f64 / 96.0
}
}
}
pub fn render_png(
ir: &TimelineIr,
opts: RenderOptions,
png_opts: PngOptions,
) -> Result<Vec<u8>, PngError> {
let layout = LayoutModel::compute(ir, opts);
let svg_str = svg::render_svg(&layout)?;
svg_to_png(&svg_str, png_opts)
}
pub fn svg_to_png(svg_str: &str, png_opts: PngOptions) -> Result<Vec<u8>, PngError> {
let factor = png_opts.pixel_scale();
let mut opt = Options::default();
opt.fontdb_mut().load_system_fonts();
let tree = Tree::from_data(svg_str.as_bytes(), &opt)?;
let size = tree.size().to_int_size();
let base_width = size.width();
let base_height = size.height();
let width = ((base_width as f64 * factor).round() as u32).max(1);
let height = ((base_height as f64 * factor).round() as u32).max(1);
let mut pixmap = Pixmap::new(width, height).ok_or(PngError::PixmapAlloc { width, height })?;
let transform = Transform::from_scale(factor as f32, factor as f32);
resvg::render(&tree, transform, &mut pixmap.as_mut());
pixmap
.encode_png()
.map_err(|e| PngError::Encode(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use tdsl_core::ir::{Item, Lane, Meta, TimelineIr};
fn sample_ir() -> TimelineIr {
TimelineIr {
meta: Meta {
title: "サンプル年表".into(),
unit: "year".into(),
range: (-300, 300),
calendar: "proleptic_gregorian".into(),
color_map: std::collections::HashMap::new(),
..Default::default()
},
lanes: vec![Lane {
id: "han".into(),
label: "漢".into(),
kind: "dynasty".into(),
order: 10,
group: None,
source_span: None,
}],
items: vec![Item::Span {
id: "span:han".into(),
lane: "han".into(),
start: -206,
end: 220,
label: "漢".into(),
tags: vec!["dynasty".into()],
source: Some("wd:Q7209".into()),
origin: None,
start_month: None,
start_day: None,
end_month: None,
end_day: None,
source_span: None,
}],
imports: vec![],
sources: vec![],
}
}
const PNG_SIGNATURE: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
#[test]
fn render_png_produces_valid_png_bytes() {
let ir = sample_ir();
let bytes = render_png(&ir, RenderOptions::default(), PngOptions::default())
.expect("render_png succeeds");
assert!(
bytes.starts_with(PNG_SIGNATURE),
"output should start with the PNG signature, got first 8 bytes = {:?}",
&bytes[..bytes.len().min(8)]
);
assert!(
bytes.len() > 100,
"PNG output should be larger than the bare signature, got {} bytes",
bytes.len()
);
}
#[test]
fn render_png_empty_ir_does_not_panic() {
let ir = TimelineIr {
meta: Meta {
title: "Empty".into(),
unit: "year".into(),
range: (0, 100),
calendar: "proleptic_gregorian".into(),
color_map: std::collections::HashMap::new(),
..Default::default()
},
lanes: vec![],
items: vec![],
imports: vec![],
sources: vec![],
};
let bytes = render_png(&ir, RenderOptions::default(), PngOptions::default())
.expect("render_png succeeds");
assert!(bytes.starts_with(PNG_SIGNATURE));
}
#[test]
fn svg_to_png_invalid_svg_returns_parse_error() {
let err =
svg_to_png("not-an-svg", PngOptions::default()).expect_err("invalid SVG must error");
assert!(matches!(err, PngError::Parse(_)));
}
#[test]
fn png_dpi_300_produces_larger_output_than_default() {
let ir = sample_ir();
let default_bytes = render_png(&ir, RenderOptions::default(), PngOptions::default())
.expect("default render_png succeeds");
let hires_bytes = render_png(
&ir,
RenderOptions::default(),
PngOptions {
dpi: 300,
scale_factor: None,
},
)
.expect("300 DPI render_png succeeds");
assert!(
hires_bytes.len() > default_bytes.len(),
"300 DPI PNG ({} bytes) should be larger than default 96 DPI PNG ({} bytes)",
hires_bytes.len(),
default_bytes.len()
);
assert!(hires_bytes.starts_with(PNG_SIGNATURE));
}
#[test]
fn png_scale_factor_produces_larger_output_than_default() {
let ir = sample_ir();
let default_bytes = render_png(&ir, RenderOptions::default(), PngOptions::default())
.expect("default render_png succeeds");
let scaled_bytes = render_png(
&ir,
RenderOptions::default(),
PngOptions {
dpi: 96,
scale_factor: Some(2.0),
},
)
.expect("2x scale render_png succeeds");
assert!(
scaled_bytes.len() > default_bytes.len(),
"2x scale PNG ({} bytes) should be larger than default PNG ({} bytes)",
scaled_bytes.len(),
default_bytes.len()
);
assert!(scaled_bytes.starts_with(PNG_SIGNATURE));
}
}