mod font;
mod hyphenate;
mod ir;
mod layout;
mod lower;
mod postprocess;
use crate::markdown::Token;
use crate::styling::ResolvedStyle;
use crate::{MdpError, fonts::FontConfig};
use printpdf::{PdfDocument, PdfSaveOptions};
pub fn render_to_file(
tokens: Vec<Token>,
style: ResolvedStyle,
font_config: Option<&FontConfig>,
path: impl AsRef<std::path::Path>,
) -> Result<(), MdpError> {
let path = path.as_ref();
let bytes = render_to_bytes(tokens, style, font_config)?;
std::fs::write(path, bytes).map_err(|e| MdpError::PdfError {
message: e.to_string(),
path: Some(path.display().to_string()),
suggestion: Some(
"Check that the output directory exists and you have write permissions".to_string(),
),
})
}
pub fn render_to_bytes(
tokens: Vec<Token>,
style: ResolvedStyle,
font_config: Option<&FontConfig>,
) -> Result<Vec<u8>, MdpError> {
let doc_title = style
.metadata
.title
.clone()
.unwrap_or_else(|| "markdown2pdf".to_string());
let mut doc = PdfDocument::new(&doc_title);
{
let info = &mut doc.metadata.info;
info.document_title = doc_title.clone();
if let Some(a) = &style.metadata.author {
info.author = a.clone();
}
if let Some(s) = &style.metadata.subject {
info.subject = s.clone();
}
if let Some(c) = &style.metadata.creator {
info.creator = c.clone();
}
if !style.metadata.keywords.is_empty() {
info.keywords = style.metadata.keywords.clone();
}
}
let body_text = Token::collect_all_text(&tokens);
let used_codepoints: Vec<char> = {
let mut chars: Vec<char> = body_text.chars().collect();
chars.sort();
chars.dedup();
chars
};
let blocks = lower::lower(&tokens);
let usage = ir::VariantUsage::analyze(&blocks);
let font_set = font::FontSet::load(font_config, &used_codepoints, usage, &mut doc);
let pages = layout::lay_out_pages(&blocks, &style, &font_set, &mut doc);
let (fallback_w, fallback_h) = layout::page_dimensions_mm(&style.page);
let pages = if pages.is_empty() {
vec![printpdf::PdfPage::new(
printpdf::Mm(fallback_w),
printpdf::Mm(fallback_h),
Vec::new(),
)]
} else {
pages
};
let mut warnings = Vec::new();
let bytes = doc
.with_pages(pages)
.save(&PdfSaveOptions::default(), &mut warnings);
for w in &warnings {
log::warn!("printpdf: {:?}", w);
}
let tooltips = postprocess::collect_link_tooltips(&tokens);
let bytes = postprocess::inject_link_tooltips(bytes, &tooltips);
let bytes = match &style.metadata.language {
Some(lang) => postprocess::inject_lang(bytes, lang),
None => bytes,
};
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::Token;
fn default_style() -> ResolvedStyle {
ResolvedStyle::default()
}
#[test]
fn empty_token_stream_produces_valid_pdf() {
let bytes = render_to_bytes(vec![], default_style(), None).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn paragraph_produces_valid_pdf() {
let tokens = vec![Token::Text("hello world".to_string())];
let bytes = render_to_bytes(tokens, default_style(), None).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn heading_produces_valid_pdf() {
let tokens = vec![Token::Heading(vec![Token::Text("Hi".into())], 1)];
let bytes = render_to_bytes(tokens, default_style(), None).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn long_document_splits_pages() {
let mut tokens = Vec::new();
for i in 0..150 {
tokens.push(Token::Text(format!("paragraph {}", i)));
tokens.push(Token::Newline);
tokens.push(Token::Newline);
}
let bytes = render_to_bytes(tokens, default_style(), None).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
assert!(bytes.len() > 1000);
}
#[test]
fn render_to_file_creates_file() {
let path = std::env::temp_dir().join("m2p_phase1.pdf");
let path_s = path.to_str().unwrap();
let tokens = vec![
Token::Heading(vec![Token::Text("Hello".into())], 1),
Token::Text("World".into()),
];
render_to_file(tokens, default_style(), None, path_s).unwrap();
assert!(path.exists());
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
let _ = std::fs::remove_file(&path);
}
}