use std::collections::HashMap;
use pdf_writer::{Content, Finish, Name, Pdf, Ref, TextStr};
use svg2pdf::usvg::{Options, Tree};
use tdsl_core::ir::TimelineIr;
use thiserror::Error;
use crate::layout::{LayoutModel, RenderOptions};
use crate::svg;
#[derive(Debug, Error)]
pub enum PdfError {
#[error("SVG formatting failed: {0}")]
Fmt(#[from] std::fmt::Error),
#[error("failed to parse intermediate SVG: {0}")]
Parse(#[from] svg2pdf::usvg::Error),
#[error("failed to convert SVG to PDF: {0}")]
Convert(String),
#[error("invalid PDF margin: {0}")]
InvalidMargin(String),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PdfPageSize {
#[default]
A4,
A3,
Letter,
}
impl PdfPageSize {
fn portrait_pt(self) -> (f32, f32) {
match self {
PdfPageSize::A4 => (595.276, 841.890), PdfPageSize::A3 => (841.890, 1190.551), PdfPageSize::Letter => (612.0, 792.0), }
}
fn name(self) -> &'static str {
match self {
PdfPageSize::A4 => "A4",
PdfPageSize::A3 => "A3",
PdfPageSize::Letter => "Letter",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct PdfDate {
pub year: u16,
pub month: u8,
pub day: u8,
}
#[derive(Debug, Clone)]
pub struct PdfOptions {
pub page_size: PdfPageSize,
pub landscape: bool,
pub margin_mm: f64,
pub title: Option<String>,
pub creation_date: Option<PdfDate>,
}
impl Default for PdfOptions {
fn default() -> Self {
Self {
page_size: PdfPageSize::default(),
landscape: false,
margin_mm: 10.0,
title: None,
creation_date: None,
}
}
}
pub fn render_pdf(
ir: &TimelineIr,
opts: RenderOptions,
mut pdf_opts: PdfOptions,
) -> Result<Vec<u8>, PdfError> {
let layout = LayoutModel::compute(ir, opts);
let svg_str = svg::render_svg(&layout)?;
if pdf_opts.title.is_none() && !ir.meta.title.is_empty() {
pdf_opts.title = Some(ir.meta.title.clone());
}
svg_to_pdf(&svg_str, pdf_opts)
}
pub fn svg_to_pdf(svg_str: &str, pdf_opts: PdfOptions) -> Result<Vec<u8>, PdfError> {
let mut opt = Options::default();
opt.fontdb_mut().load_system_fonts();
let tree = Tree::from_str(svg_str, &opt)?;
let (mut pw, mut ph) = pdf_opts.page_size.portrait_pt();
if pdf_opts.landscape {
std::mem::swap(&mut pw, &mut ph);
}
if !pdf_opts.margin_mm.is_finite() || pdf_opts.margin_mm < 0.0 {
return Err(PdfError::InvalidMargin(format!(
"margin must be a non-negative, finite number of millimetres, got {}",
pdf_opts.margin_mm
)));
}
let margin = (pdf_opts.margin_mm * 72.0 / 25.4) as f32;
if 2.0 * margin >= pw.min(ph) {
let orientation = if pdf_opts.landscape {
"landscape"
} else {
"portrait"
};
return Err(PdfError::InvalidMargin(format!(
"margin {} mm is too large for the {} {} page; it leaves no printable area",
pdf_opts.margin_mm,
pdf_opts.page_size.name(),
orientation,
)));
}
let content_w = pw - 2.0 * margin;
let content_h = ph - 2.0 * margin;
let svg_size = tree.size();
let svg_w = svg_size.width();
let svg_h = svg_size.height();
let scale = (content_w / svg_w).min(content_h / svg_h);
let draw_w = svg_w * scale;
let draw_h = svg_h * scale;
let tx = margin + (content_w - draw_w) / 2.0;
let ty = margin + (content_h - draw_h) / 2.0;
let (svg_chunk_raw, svg_old_id) =
svg2pdf::to_chunk(&tree, svg2pdf::ConversionOptions::default())
.map_err(|e| PdfError::Convert(e.to_string()))?;
let mut alloc = Ref::new(1);
let catalog_id = alloc.bump();
let page_tree_id = alloc.bump();
let page_id = alloc.bump();
let content_id = alloc.bump();
let info_id = alloc.bump();
let mut id_map: HashMap<Ref, Ref> = HashMap::new();
let svg_chunk =
svg_chunk_raw.renumber(|old| *id_map.entry(old).or_insert_with(|| alloc.bump()));
let svg_id = *id_map
.get(&svg_old_id)
.ok_or_else(|| PdfError::Convert("svg chunk renumber: XObject ID not found".to_string()))?;
let svg_name = Name(b"S1");
let mut pdf = Pdf::new();
pdf.catalog(catalog_id).pages(page_tree_id);
pdf.pages(page_tree_id).kids([page_id]).count(1);
{
let mut page = pdf.page(page_id);
page.media_box(pdf_writer::Rect::new(0.0, 0.0, pw, ph));
page.parent(page_tree_id);
page.contents(content_id);
let mut resources = page.resources();
resources.x_objects().pair(svg_name, svg_id);
resources.finish();
page.finish();
}
let mut content = Content::new();
content
.save_state()
.transform([draw_w, 0.0, 0.0, draw_h, tx, ty])
.x_object(svg_name)
.restore_state();
pdf.stream(content_id, &content.finish());
pdf.extend(&svg_chunk);
{
let mut info = pdf.document_info(info_id);
info.producer(TextStr("tdsl (svg2pdf)"));
if let Some(ref title) = pdf_opts.title {
info.title(TextStr(title.as_str()));
}
if let Some(d) = pdf_opts.creation_date {
info.creation_date(pdf_writer::Date::new(d.year).month(d.month).day(d.day));
}
info.finish();
}
Ok(pdf.finish())
}
#[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 PDF_SIGNATURE: &[u8] = &[0x25, 0x50, 0x44, 0x46, 0x2D];
#[test]
fn render_pdf_produces_valid_pdf_bytes() {
let ir = sample_ir();
let bytes = render_pdf(&ir, RenderOptions::default(), PdfOptions::default())
.expect("render_pdf succeeds");
assert!(
bytes.starts_with(PDF_SIGNATURE),
"output should start with the PDF signature %%PDF-, got first 5 bytes = {:?}",
&bytes[..bytes.len().min(5)]
);
assert!(
bytes.len() > 100,
"PDF output should be larger than the bare signature, got {} bytes",
bytes.len()
);
}
#[test]
fn render_pdf_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_pdf(&ir, RenderOptions::default(), PdfOptions::default())
.expect("render_pdf on empty IR succeeds");
assert!(bytes.starts_with(PDF_SIGNATURE));
}
#[test]
fn svg_to_pdf_invalid_svg_returns_parse_error() {
let err =
svg_to_pdf("not-an-svg", PdfOptions::default()).expect_err("invalid SVG must error");
assert!(
matches!(err, PdfError::Parse(_)),
"expected PdfError::Parse, got: {err}"
);
}
#[test]
fn render_pdf_cjk_lane_label_does_not_panic() {
let ir = sample_ir();
let bytes = render_pdf(&ir, RenderOptions::default(), PdfOptions::default())
.expect("render_pdf with CJK label succeeds");
assert!(bytes.starts_with(PDF_SIGNATURE));
assert!(bytes.len() > 1000, "PDF should be non-trivially sized");
}
#[test]
fn pdf_a3_produces_valid_pdf() {
let opts = PdfOptions {
page_size: PdfPageSize::A3,
..PdfOptions::default()
};
let bytes = svg_to_pdf(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect width="100" height="50" fill="blue"/></svg>"#,
opts,
)
.expect("A3 PDF generation succeeds");
assert!(bytes.starts_with(PDF_SIGNATURE), "A3 output must be a PDF");
assert!(bytes.len() > 100);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/MediaBox"),
"PDF must contain a /MediaBox entry"
);
}
#[test]
fn pdf_letter_produces_valid_pdf() {
let opts = PdfOptions {
page_size: PdfPageSize::Letter,
..PdfOptions::default()
};
let bytes = svg_to_pdf(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect width="100" height="50" fill="red"/></svg>"#,
opts,
)
.expect("Letter PDF generation succeeds");
assert!(bytes.starts_with(PDF_SIGNATURE));
}
#[test]
fn pdf_landscape_produces_valid_pdf() {
let opts = PdfOptions {
page_size: PdfPageSize::A4,
landscape: true,
..PdfOptions::default()
};
let bytes = svg_to_pdf(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200"><rect width="800" height="200" fill="green"/></svg>"#,
opts,
)
.expect("landscape PDF generation succeeds");
assert!(bytes.starts_with(PDF_SIGNATURE));
}
#[test]
fn pdf_a4_and_a3_media_boxes_differ() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect width="100" height="50"/></svg>"#;
let bytes_a4 = svg_to_pdf(svg, PdfOptions::default()).expect("A4 PDF generation succeeds");
let bytes_a3 = svg_to_pdf(
svg,
PdfOptions {
page_size: PdfPageSize::A3,
..PdfOptions::default()
},
)
.expect("A3 PDF generation succeeds");
assert!(bytes_a4.starts_with(PDF_SIGNATURE));
assert!(bytes_a3.starts_with(PDF_SIGNATURE));
assert_ne!(
bytes_a4, bytes_a3,
"A4 and A3 PDFs must differ (different page sizes)"
);
}
const TINY_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#;
#[test]
fn pdf_oversized_margin_returns_error() {
let opts = PdfOptions {
margin_mm: 500.0,
..PdfOptions::default()
};
let err = svg_to_pdf(TINY_SVG, opts).expect_err("over-large margin must error");
assert!(
matches!(err, PdfError::InvalidMargin(_)),
"expected PdfError::InvalidMargin, got: {err}"
);
}
#[test]
fn pdf_negative_margin_returns_error() {
let opts = PdfOptions {
margin_mm: -5.0,
..PdfOptions::default()
};
let err = svg_to_pdf(TINY_SVG, opts).expect_err("negative margin must error");
assert!(
matches!(err, PdfError::InvalidMargin(_)),
"expected PdfError::InvalidMargin, got: {err}"
);
}
#[test]
fn pdf_non_finite_margin_returns_error() {
let opts = PdfOptions {
margin_mm: f64::NAN,
..PdfOptions::default()
};
let err = svg_to_pdf(TINY_SVG, opts).expect_err("NaN margin must error");
assert!(matches!(err, PdfError::InvalidMargin(_)));
}
#[test]
fn pdf_large_but_valid_margin_still_renders() {
let opts = PdfOptions {
margin_mm: 90.0,
..PdfOptions::default()
};
let bytes = svg_to_pdf(TINY_SVG, opts).expect("valid large margin renders");
assert!(bytes.starts_with(PDF_SIGNATURE));
}
#[test]
fn pdf_with_title_and_creation_date_produces_valid_pdf() {
let opts = PdfOptions {
title: Some("My Timeline".to_string()),
creation_date: Some(PdfDate {
year: 2026,
month: 6,
day: 7,
}),
..PdfOptions::default()
};
let bytes = svg_to_pdf(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect width="100" height="50" fill="navy"/></svg>"#,
opts,
)
.expect("PDF with metadata succeeds");
assert!(bytes.starts_with(PDF_SIGNATURE));
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/CreationDate"),
"PDF must contain /CreationDate when creation_date is set"
);
}
#[test]
fn render_pdf_title_is_filled_from_ir_meta_when_none() {
let ir = sample_ir(); let opts = PdfOptions {
title: None, ..PdfOptions::default()
};
let bytes = render_pdf(&ir, RenderOptions::default(), opts).expect("render_pdf succeeds");
assert!(bytes.starts_with(PDF_SIGNATURE));
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/Title"),
"PDF must contain /Title when ir.meta.title is non-empty"
);
}
}