use crate::core::{Rect, Size};
use crate::widget::svg::render_widget_to_svg;
use crate::widget::Draw;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PageSize {
A4,
Letter,
Custom { width: f32, height: f32 },
}
impl PageSize {
pub fn dimensions(&self) -> (f32, f32) {
match self {
PageSize::A4 => (595.28, 841.89),
PageSize::Letter => (612.0, 792.0),
PageSize::Custom { width, height } => (*width, *height),
}
}
pub fn width(&self) -> f32 {
self.dimensions().0
}
pub fn height(&self) -> f32 {
self.dimensions().1
}
pub fn to_size(&self, dpi: u32) -> Size {
let (w, h) = self.dimensions();
let scale = dpi as f32 / 72.0;
Size { width: (w * scale).round() as u32, height: (h * scale).round() as u32 }
}
}
impl Default for PageSize {
fn default() -> Self {
PageSize::A4
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PdfOrientation {
Portrait,
Landscape,
}
impl Default for PdfOrientation {
fn default() -> Self {
PdfOrientation::Portrait
}
}
impl PdfOrientation {
pub fn apply(&self, (w, h): (f32, f32)) -> (f32, f32) {
match self {
PdfOrientation::Portrait => (w, h),
PdfOrientation::Landscape => (h, w),
}
}
}
#[derive(Debug, Clone)]
pub struct PdfExportSettings {
pub page_size: PageSize,
pub orientation: PdfOrientation,
pub margins: [f32; 4],
pub dpi: u32,
}
impl Default for PdfExportSettings {
fn default() -> Self {
Self {
page_size: PageSize::A4,
orientation: PdfOrientation::Portrait,
margins: [56.0, 56.0, 56.0, 56.0], dpi: 72,
}
}
}
impl PdfExportSettings {
pub fn new() -> Self {
Self::default()
}
pub fn effective_dimensions(&self) -> (f32, f32) {
self.orientation.apply(self.page_size.dimensions())
}
pub fn content_width(&self) -> f32 {
let (w, _) = self.effective_dimensions();
w - self.margins[1] - self.margins[3]
}
pub fn content_height(&self) -> f32 {
let (_, h) = self.effective_dimensions();
h - self.margins[0] - self.margins[2]
}
pub fn pixel_size(&self) -> Size {
let (w, h) = self.effective_dimensions();
let scale = self.dpi as f32 / 72.0;
Size { width: (w * scale).round() as u32, height: (h * scale).round() as u32 }
}
}
#[derive(Debug, Clone)]
pub struct PdfPage {
pub index: u32,
pub svg_content: String,
pub width_pt: f32,
pub height_pt: f32,
pub width_px: u32,
pub height_px: u32,
}
impl PdfPage {
pub fn new(
index: u32,
svg_content: String,
width_pt: f32,
height_pt: f32,
width_px: u32,
height_px: u32,
) -> Self {
Self { index, svg_content, width_pt, height_pt, width_px, height_px }
}
}
#[derive(Debug)]
pub struct PdfExporter {
pub settings: PdfExportSettings,
}
impl PdfExporter {
pub fn new() -> Self {
Self { settings: PdfExportSettings::new() }
}
pub fn with_settings(settings: PdfExportSettings) -> Self {
Self { settings }
}
pub fn export(&self, widgets: &mut [&mut dyn Draw], path: &str) -> Result<(), String> {
let pages = self.render_pages(widgets)?;
let pdf_bytes = build_svg_pdf(&pages, &self.settings)?;
std::fs::write(path, &pdf_bytes)
.map_err(|err| format!("failed to write PDF file '{path}': {err}"))?;
Ok(())
}
pub fn render_pages(&self, widgets: &mut [&mut dyn Draw]) -> Result<Vec<PdfPage>, String> {
let pixel_size = self.settings.pixel_size();
let (page_w_pt, page_h_pt) = self.settings.effective_dimensions();
let mut pages = Vec::with_capacity(widgets.len());
for (idx, widget) in widgets.iter_mut().enumerate() {
let svg =
render_widget_to_svg(*widget, Rect::new(0, 0, pixel_size.width, pixel_size.height));
pages.push(PdfPage::new(
idx as u32,
svg,
page_w_pt,
page_h_pt,
pixel_size.width,
pixel_size.height,
));
}
Ok(pages)
}
}
impl Default for PdfExporter {
fn default() -> Self {
Self::new()
}
}
fn build_svg_pdf(pages: &[PdfPage], settings: &PdfExportSettings) -> Result<Vec<u8>, String> {
if pages.is_empty() {
return Err("at least one page is required".to_string());
}
let mut objects: Vec<Vec<u8>> = Vec::new();
objects.push(Vec::new());
objects.push(Vec::new());
let mut page_obj_ids: Vec<u32> = Vec::new();
for page in pages {
let content_stream = build_content_stream(page, settings);
let content_obj_id = (objects.len() + 1) as u32;
objects.push(
format!(
"<< /Length {} >>\nstream\n{}\nendstream",
content_stream.len(),
content_stream,
)
.into_bytes(),
);
let page_obj_id = (objects.len() + 1) as u32;
let page_obj = format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.2} {:.2}] /Contents {} 0 R >>",
page.width_pt, page.height_pt, content_obj_id,
);
objects.push(page_obj.into_bytes());
page_obj_ids.push(page_obj_id);
}
let info_obj_id = (objects.len() + 1) as u32;
objects.push(
b"<< /Title (Exported Document) /Creator (rust-widgets PdfExporter) /Producer (rust-widgets) >>"
.to_vec(),
);
objects[0] = b"<< /Type /Catalog /Pages 2 0 R >>".to_vec();
let kids = page_obj_ids.iter().map(|id| format!("{} 0 R", id)).collect::<Vec<_>>().join(" ");
objects[1] =
format!("<< /Type /Pages /Count {} /Kids [{}] >>", page_obj_ids.len(), kids,).into_bytes();
let mut out: Vec<u8> = Vec::new();
out.extend_from_slice(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
let mut offsets: Vec<usize> = Vec::new();
for (idx, body) in objects.iter().enumerate() {
offsets.push(out.len());
let obj_id = idx + 1;
out.extend_from_slice(format!("{} 0 obj\n", obj_id).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref_offset = out.len();
out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
out.extend_from_slice(b"0000000000 65535 f \n");
for offset in offsets {
out.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R /Info {} 0 R >>\nstartxref\n{}\n%%EOF\n",
objects.len() + 1,
info_obj_id,
xref_offset,
)
.as_bytes(),
);
Ok(out)
}
fn build_content_stream(page: &PdfPage, _settings: &PdfExportSettings) -> String {
let mut stream = String::new();
stream.push_str(&format!(
"q\n{:.4} 0 0 {:.4} 0 0 cm\n",
page.width_pt / page.width_px as f32,
page.height_pt / page.height_px as f32,
));
stream.push_str("% BEGIN SVG CONTENT\n");
for line in page.svg_content.lines() {
stream.push_str(&format!("% {}\n", line));
}
stream.push_str("% END SVG CONTENT\n");
stream.push_str("Q\n");
stream
}
pub fn export_to_pdf(widgets: &mut [&mut dyn Draw], path: &str) -> Result<(), String> {
let exporter = PdfExporter::new();
exporter.export(widgets, path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Rect;
use std::sync::{Arc, Mutex};
struct TestWidget {
#[allow(dead_code)]
geometry: Rect,
draw_count: Arc<Mutex<u32>>,
}
impl TestWidget {
fn new(width: u32, height: u32) -> Self {
Self { geometry: Rect::new(0, 0, width, height), draw_count: Arc::new(Mutex::new(0)) }
}
}
impl Draw for TestWidget {
fn draw(&mut self, _context: &mut crate::render::RenderContext) {
*self.draw_count.lock().unwrap() += 1;
}
fn uses_custom_drawing(&self) -> bool {
true
}
}
#[test]
fn pdf_exporter_default_settings() {
let exporter = PdfExporter::new();
assert_eq!(exporter.settings.page_size, PageSize::A4);
assert_eq!(exporter.settings.orientation, PdfOrientation::Portrait);
assert_eq!(exporter.settings.dpi, 72);
}
#[test]
fn pdf_exporter_with_custom_settings() {
let settings = PdfExportSettings {
page_size: PageSize::Letter,
orientation: PdfOrientation::Landscape,
margins: [72.0, 72.0, 72.0, 72.0],
dpi: 150,
};
let exporter = PdfExporter::with_settings(settings.clone());
assert_eq!(exporter.settings.page_size, PageSize::Letter);
assert_eq!(exporter.settings.orientation, PdfOrientation::Landscape);
assert_eq!(exporter.settings.dpi, 150);
}
#[test]
fn page_size_dimensions() {
let (aw, ah) = PageSize::A4.dimensions();
assert!((aw - 595.28).abs() < 0.01);
assert!((ah - 841.89).abs() < 0.01);
let (lw, lh) = PageSize::Letter.dimensions();
assert!((lw - 612.0).abs() < 0.01);
assert!((lh - 792.0).abs() < 0.01);
let (cw, ch) = PageSize::Custom { width: 300.0, height: 400.0 }.dimensions();
assert!((cw - 300.0).abs() < 0.01);
assert!((ch - 400.0).abs() < 0.01);
}
#[test]
fn orientation_applies_correctly() {
let (w, h) = PdfOrientation::Portrait.apply((612.0, 792.0));
assert!((w - 612.0).abs() < 0.01);
assert!((h - 792.0).abs() < 0.01);
let (w, h) = PdfOrientation::Landscape.apply((612.0, 792.0));
assert!((w - 792.0).abs() < 0.01);
assert!((h - 612.0).abs() < 0.01);
}
#[test]
fn export_settings_content_area() {
let settings = PdfExportSettings {
page_size: PageSize::A4,
orientation: PdfOrientation::Portrait,
margins: [72.0, 72.0, 72.0, 72.0],
dpi: 72,
};
let cw = settings.content_width();
let ch = settings.content_height();
assert!((cw - (595.28 - 144.0)).abs() < 0.01);
assert!((ch - (841.89 - 144.0)).abs() < 0.01);
}
#[test]
fn pdf_exporter_render_pages() {
let mut widget = TestWidget::new(100, 50);
let mut widgets: [&mut dyn Draw; 1] = [&mut widget];
let exporter = PdfExporter::new();
let pages = exporter.render_pages(&mut widgets).expect("render pages");
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].index, 0);
assert!(pages[0].svg_content.contains("<svg"));
}
#[test]
fn export_to_pdf_empty_widgets() {
let mut empty: [&mut dyn Draw; 0] = [];
let result = export_to_pdf(&mut empty, "/tmp/nonexistent.pdf");
assert!(result.is_err());
}
#[test]
fn build_svg_pdf_produces_valid_pdf_header() {
let pages = vec![PdfPage::new(
0,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"50\"><rect width=\"100\" height=\"50\" fill=\"red\"/></svg>".to_string(),
595.28,
841.89,
100,
50,
)];
let settings = PdfExportSettings::new();
let pdf = build_svg_pdf(&pages, &settings).expect("build pdf");
let text = String::from_utf8_lossy(&pdf);
assert!(text.starts_with("%PDF-1.4"));
assert!(text.contains("/Type /Catalog"));
assert!(text.contains("/Type /Pages"));
assert!(text.contains("/Type /Page"));
assert!(text.contains("/MediaBox [0 0 595.28 841.89]"));
assert!(text.contains("% BEGIN SVG CONTENT"));
assert!(text.contains("% END SVG CONTENT"));
assert!(text.contains("startxref"));
assert!(text.contains("%%EOF"));
}
#[test]
fn build_svg_pdf_empty_pages_returns_error() {
let pages = vec![];
let settings = PdfExportSettings::new();
let result = build_svg_pdf(&pages, &settings);
assert!(result.is_err());
}
}