use crate::{styling::StyleMatch, Token};
use genpdfi::{
fonts::{FontData, FontFamily},
Alignment, Document,
};
use log::warn;
pub struct Pdf {
input: Vec<Token>,
style: StyleMatch,
font_family: FontFamily<FontData>,
#[allow(dead_code)] code_font_family: FontFamily<FontData>,
}
impl Pdf {
pub fn new(
input: Vec<Token>,
style: StyleMatch,
font_config: Option<&crate::fonts::FontConfig>,
) -> Result<Self, crate::MdpError> {
let family_name = font_config
.and_then(|cfg| cfg.default_font.as_deref())
.or(style.text.font_family)
.unwrap_or("Helvetica");
let is_builtin = matches!(
family_name.to_lowercase().as_str(),
"helvetica"
| "arial"
| "sans-serif"
| "times"
| "times new roman"
| "serif"
| "courier"
| "courier new"
| "monospace"
);
let font_err = |name: &str, e: &dyn std::fmt::Display| crate::MdpError::FontError {
font_name: name.to_string(),
message: e.to_string(),
suggestion: "Ensure font files are accessible or use a built-in font (Helvetica, Times, Courier).".to_string(),
};
let font_family = if let Some(source) =
font_config.and_then(|c| c.default_font_source.clone())
{
match crate::fonts::load_font_family(source) {
Ok(font) => font,
Err(e) => {
warn!("Could not load font from source: {}. Using Helvetica.", e);
crate::fonts::load_builtin_font_family("Helvetica")
.map_err(|e| font_err("Helvetica", &e))?
}
}
} else if is_builtin {
crate::fonts::load_builtin_font_family(family_name)
.map_err(|e| font_err(family_name, &e))?
} else {
let text = if font_config.map(|c| c.enable_subsetting).unwrap_or(true) {
Some(Token::collect_all_text(&input))
} else {
None
};
match crate::fonts::load_font(family_name, font_config, text.as_deref()) {
Ok(font) => font,
Err(e) => {
warn!(
"Could not load font '{}': {}. Using Helvetica.",
family_name, e
);
crate::fonts::load_builtin_font_family("Helvetica")
.map_err(|e| font_err("Helvetica", &e))?
}
}
};
let code_font_name = font_config
.and_then(|cfg| cfg.code_font.as_deref())
.unwrap_or("Courier");
let code_font_family = if let Some(source) =
font_config.and_then(|c| c.code_font_source.clone())
{
match crate::fonts::load_font_family(source) {
Ok(font) => font,
Err(e) => {
warn!("Could not load code font from source: {}. Using Courier.", e);
crate::fonts::load_builtin_font_family("Courier")
.map_err(|e| font_err("Courier", &e))?
}
}
} else {
match crate::fonts::load_builtin_font_family(code_font_name) {
Ok(font) => font,
Err(_) => crate::fonts::load_builtin_font_family("Courier")
.map_err(|e| font_err("Courier", &e))?,
}
};
Ok(Self {
input,
style,
font_family,
code_font_family,
})
}
pub fn render(document: genpdfi::Document, path: &str) -> Option<String> {
match document.render_to_file(path) {
Ok(_) => None,
Err(err) => Some(err.to_string()),
}
}
pub fn render_to_bytes(document: genpdfi::Document) -> Result<Vec<u8>, String> {
let mut buffer = std::io::Cursor::new(Vec::new());
match document.render(&mut buffer) {
Ok(_) => Ok(buffer.into_inner()),
Err(err) => Err(err.to_string()),
}
}
pub fn render_into_document(&self) -> Document {
let mut doc = genpdfi::Document::new(self.font_family.clone());
let mut decorator = genpdfi::SimplePageDecorator::new();
decorator.set_margins(genpdfi::Margins::trbl(
self.style.margins.top,
self.style.margins.right,
self.style.margins.bottom,
self.style.margins.left,
));
doc.set_page_decorator(decorator);
doc.set_font_size(self.style.text.size);
self.process_tokens(&mut doc);
doc
}
fn process_tokens(&self, doc: &mut Document) {
let mut current_tokens = Vec::new();
for token in &self.input {
match token {
Token::Heading(content, level) => {
self.flush_paragraph(doc, ¤t_tokens);
current_tokens.clear();
self.render_heading(doc, content, *level);
}
Token::ListItem {
content,
ordered,
number,
} => {
self.flush_paragraph(doc, ¤t_tokens);
current_tokens.clear();
self.render_list_item(doc, content, *ordered, *number, 0);
}
Token::Code(lang, content) if content.contains('\n') => {
self.flush_paragraph(doc, ¤t_tokens);
current_tokens.clear();
self.render_code_block(doc, lang, content);
}
Token::HorizontalRule => {
self.flush_paragraph(doc, ¤t_tokens);
current_tokens.clear();
doc.push(genpdfi::elements::Break::new(
self.style.horizontal_rule.after_spacing,
));
}
Token::Newline => {
self.flush_paragraph(doc, ¤t_tokens);
current_tokens.clear();
}
Token::Table {
headers,
aligns,
rows,
} => {
self.flush_paragraph(doc, ¤t_tokens);
current_tokens.clear();
self.render_table(doc, headers, aligns, rows)
}
_ => {
current_tokens.push(token.clone());
}
}
}
self.flush_paragraph(doc, ¤t_tokens);
}
fn flush_paragraph(&self, doc: &mut Document, tokens: &[Token]) {
if tokens.is_empty() {
return;
}
doc.push(genpdfi::elements::Break::new(
self.style.text.before_spacing,
));
let mut para = genpdfi::elements::Paragraph::default();
self.render_inline_content(&mut para, tokens);
doc.push(para);
doc.push(genpdfi::elements::Break::new(self.style.text.after_spacing));
}
fn render_heading(&self, doc: &mut Document, content: &[Token], level: usize) {
let heading_style = match level {
1 => &self.style.heading_1,
2 => &self.style.heading_2,
3 | _ => &self.style.heading_3,
};
doc.push(genpdfi::elements::Break::new(heading_style.before_spacing));
let mut para = genpdfi::elements::Paragraph::default();
let mut style = genpdfi::style::Style::new().with_font_size(heading_style.size);
if heading_style.bold {
style = style.bold();
}
if heading_style.italic {
style = style.italic();
}
if let Some(color) = heading_style.text_color {
style = style.with_color(genpdfi::style::Color::Rgb(color.0, color.1, color.2));
}
self.render_inline_content_with_style(&mut para, content, style);
doc.push(para);
doc.push(genpdfi::elements::Break::new(heading_style.after_spacing));
}
fn render_inline_content_with_style(
&self,
para: &mut genpdfi::elements::Paragraph,
tokens: &[Token],
style: genpdfi::style::Style,
) {
for token in tokens {
match token {
Token::Text(content) => {
para.push_styled(content.clone(), style.clone());
}
Token::Emphasis { level, content } => {
let mut nested_style = style.clone();
match level {
1 => nested_style = nested_style.italic(),
2 => nested_style = nested_style.bold(),
_ => nested_style = nested_style.bold().italic(),
}
self.render_inline_content_with_style(para, content, nested_style);
}
Token::StrongEmphasis(content) => {
let nested_style = style.clone().bold();
self.render_inline_content_with_style(para, content, nested_style);
}
Token::Link(text, url) => {
let mut link_style = style.clone();
if let Some(color) = self.style.link.text_color {
link_style = link_style
.with_color(genpdfi::style::Color::Rgb(color.0, color.1, color.2));
}
if self.style.link.bold {
link_style = link_style.bold();
}
if self.style.link.italic {
link_style = link_style.italic();
}
if self.style.link.underline {
link_style = link_style.underline();
}
if self.style.link.strikethrough {
link_style = link_style.strikethrough();
}
para.push_link(text.clone(), url.clone(), link_style);
}
Token::Code(_, content) => {
let mut code_style = style.clone();
if let Some(color) = self.style.code.text_color {
code_style = code_style
.with_color(genpdfi::style::Color::Rgb(color.0, color.1, color.2));
}
para.push_styled(content.clone(), code_style);
}
_ => {}
}
}
}
fn render_inline_content(&self, para: &mut genpdfi::elements::Paragraph, tokens: &[Token]) {
let style = genpdfi::style::Style::new().with_font_size(self.style.text.size);
self.render_inline_content_with_style(para, tokens, style);
}
fn render_code_block(&self, doc: &mut Document, _lang: &str, content: &str) {
doc.push(genpdfi::elements::Break::new(
self.style.code.before_spacing,
));
let mut style = genpdfi::style::Style::new().with_font_size(self.style.code.size);
if let Some(color) = self.style.code.text_color {
style = style.with_color(genpdfi::style::Color::Rgb(color.0, color.1, color.2));
}
let indent = " "; for line in content.split('\n') {
let mut para = genpdfi::elements::Paragraph::default();
para.push_styled(format!("{}{}", indent, line), style.clone());
doc.push(para);
}
doc.push(genpdfi::elements::Break::new(self.style.code.after_spacing));
}
fn render_list_item(
&self,
doc: &mut Document,
content: &[Token],
ordered: bool,
number: Option<usize>,
nesting_level: usize,
) {
doc.push(genpdfi::elements::Break::new(
self.style.list_item.before_spacing,
));
let mut para = genpdfi::elements::Paragraph::default();
let style = genpdfi::style::Style::new().with_font_size(self.style.list_item.size);
let indent = " ".repeat(nesting_level);
if !ordered {
para.push_styled(format!("{}- ", indent), style.clone());
} else if let Some(n) = number {
para.push_styled(format!("{}{}. ", indent, n), style.clone());
}
let inline_content: Vec<Token> = content
.iter()
.filter(|token| !matches!(token, Token::ListItem { .. }))
.cloned()
.collect();
self.render_inline_content_with_style(&mut para, &inline_content, style);
doc.push(para);
doc.push(genpdfi::elements::Break::new(
self.style.list_item.after_spacing,
));
for token in content {
if let Token::ListItem {
content: nested_content,
ordered: nested_ordered,
number: nested_number,
} = token
{
self.render_list_item(
doc,
nested_content,
*nested_ordered,
*nested_number,
nesting_level + 1,
);
}
}
}
fn render_table(
&self,
doc: &mut Document,
headers: &Vec<Vec<Token>>,
aligns: &Vec<Alignment>,
rows: &Vec<Vec<Vec<Token>>>,
) {
doc.push(genpdfi::elements::Break::new(
self.style.text.before_spacing,
));
let column_count = headers.len();
let column_weights = vec![1; column_count];
let mut table = genpdfi::elements::TableLayout::new(column_weights);
table.set_cell_decorator(genpdfi::elements::FrameCellDecorator::new(
true, true, false,
));
let mut header_row = table.row();
for (i, header_cell) in headers.iter().enumerate() {
let mut para = genpdfi::elements::Paragraph::default();
let style = genpdfi::style::Style::new().with_font_size(self.style.table_header.size);
if let Some(align) = aligns.get(i) {
para.set_alignment(*align);
}
self.render_inline_content_with_style(&mut para, header_cell, style);
header_row.push_element(para);
}
if let Err(_) = header_row.push() {
warn!("Failed rendering a table");
return; }
for (row_idx, row) in rows.iter().enumerate() {
let mut table_row = table.row();
for (i, cell_tokens) in row.iter().enumerate() {
let mut para = genpdfi::elements::Paragraph::default();
let style = genpdfi::style::Style::new().with_font_size(self.style.table_cell.size);
if let Some(align) = aligns.get(i) {
para.set_alignment(*align);
}
self.render_inline_content_with_style(&mut para, cell_tokens, style);
table_row.push_element(para);
}
if let Err(_) = table_row.push() {
warn!("Failed to push row {} in a table", row_idx);
continue; }
}
doc.push(table);
doc.push(genpdfi::elements::Break::new(self.style.text.after_spacing));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::styling::StyleMatch;
fn create_test_pdf(tokens: Vec<Token>) -> Pdf {
Pdf::new(tokens, StyleMatch::default(), None).expect("Failed to create test PDF")
}
#[test]
fn test_pdf_creation() {
let pdf = create_test_pdf(vec![]);
assert!(pdf.input.is_empty());
let _font_family = &pdf.font_family;
let _code_font_family = &pdf.code_font_family;
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_heading() {
let tokens = vec![
Token::Heading(vec![Token::Text("Test Heading".to_string())], 1),
Token::Heading(vec![Token::Text("Subheading".to_string())], 2),
Token::Heading(vec![Token::Text("Sub-subheading".to_string())], 3),
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_paragraphs() {
let tokens = vec![
Token::Text("First paragraph".to_string()),
Token::Newline,
Token::Text("Second paragraph".to_string()),
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_list_items() {
let tokens = vec![
Token::ListItem {
content: vec![Token::Text("First item".to_string())],
ordered: false,
number: None,
},
Token::ListItem {
content: vec![Token::Text("Second item".to_string())],
ordered: true,
number: Some(1),
},
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_nested_list_items() {
let tokens = vec![Token::ListItem {
content: vec![
Token::Text("Parent item".to_string()),
Token::ListItem {
content: vec![Token::Text("Child item".to_string())],
ordered: false,
number: None,
},
],
ordered: false,
number: None,
}];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_code_blocks() {
let tokens = vec![Token::Code(
"rust".to_string(),
"fn main() {\n println!(\"Hello\");\n}".to_string(),
)];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_inline_formatting() {
let tokens = vec![
Token::Text("Normal ".to_string()),
Token::Emphasis {
level: 1,
content: vec![Token::Text("italic".to_string())],
},
Token::Text(" and ".to_string()),
Token::StrongEmphasis(vec![Token::Text("bold".to_string())]),
Token::Text(" text".to_string()),
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_links() {
let tokens = vec![
Token::Text("Here is a ".to_string()),
Token::Link("link".to_string(), "https://example.com".to_string()),
Token::Text(" to click".to_string()),
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_horizontal_rule() {
let tokens = vec![
Token::Text("Before rule".to_string()),
Token::HorizontalRule,
Token::Text("After rule".to_string()),
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_mixed_content() {
let tokens = vec![
Token::Heading(vec![Token::Text("Title".to_string())], 1),
Token::Text("Some text ".to_string()),
Token::Link("with link".to_string(), "https://example.com".to_string()),
Token::Newline,
Token::ListItem {
content: vec![Token::Text("List item".to_string())],
ordered: false,
number: None,
},
Token::Code("rust".to_string(), "let x = 42;".to_string()),
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_empty_content() {
let pdf = create_test_pdf(vec![]);
let doc = pdf.render_into_document();
assert!(Pdf::render(doc, "/dev/null").is_none());
}
#[test]
fn test_render_invalid_path() {
let pdf = create_test_pdf(vec![Token::Text("Test".to_string())]);
let doc = pdf.render_into_document();
let result = Pdf::render(doc, "/nonexistent/path/file.pdf");
assert!(result.is_some()); }
#[test]
fn test_render_to_bytes() {
let tokens = vec![
Token::Heading(vec![Token::Text("Test Document".to_string())], 1),
Token::Text("This is a test paragraph.".to_string()),
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
let result = Pdf::render_to_bytes(doc);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
#[test]
fn test_render_to_bytes_empty_document() {
let pdf = create_test_pdf(vec![]);
let doc = pdf.render_into_document();
let result = Pdf::render_to_bytes(doc);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
#[test]
fn test_render_to_bytes_complex_content() {
let tokens = vec![
Token::Heading(vec![Token::Text("Main Title".to_string())], 1),
Token::Text("Introduction paragraph.".to_string()),
Token::Heading(vec![Token::Text("Section 1".to_string())], 2),
Token::ListItem {
content: vec![Token::Text("First item".to_string())],
ordered: false,
number: None,
},
Token::ListItem {
content: vec![Token::Text("Second item".to_string())],
ordered: false,
number: None,
},
Token::Code(
"rust".to_string(),
"fn main() {\n println!(\"Hello\");\n}".to_string(),
),
Token::Link(
"Example Link".to_string(),
"https://example.com".to_string(),
),
];
let pdf = create_test_pdf(tokens);
let doc = pdf.render_into_document();
let result = Pdf::render_to_bytes(doc);
assert!(result.is_ok());
let pdf_bytes = result.unwrap();
assert!(!pdf_bytes.is_empty());
assert!(pdf_bytes.starts_with(b"%PDF-"));
}
}