mod font;
mod hyphenate;
mod ir;
mod layout;
mod lower;
mod math;
mod postprocess;
mod preprocess;
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(
mut tokens: Vec<Token>,
style: ResolvedStyle,
font_config: Option<&FontConfig>,
) -> Result<Vec<u8>, MdpError> {
preprocess::rewrite_html_anchors(&mut tokens);
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 mut usage = ir::VariantUsage::analyze(&blocks);
for block_style in style.headings.iter().chain([&style.blockquote]) {
if block_style.is_bold() && block_style.is_italic() {
usage.body_bold_italic = true;
} else if block_style.is_bold() {
usage.body_bold = true;
} else if block_style.is_italic() {
usage.body_italic = true;
}
}
let cb = &style.code_block;
if cb.is_bold() && cb.is_italic() {
usage.mono_bold_italic = true;
} else if cb.is_bold() {
usage.mono_bold = true;
} else if cb.is_italic() {
usage.mono_italic = true;
}
let code_inline_font = match (
style.code_inline.font_family.as_deref(),
style.code_block.font_family.as_deref(),
) {
(Some(ci), Some(cb)) if ci.eq_ignore_ascii_case(cb) => None,
(ci, _) => ci,
};
let font_set = font::FontSet::load_with_style_fallbacks(
font_config,
&style.fallback_fonts,
code_inline_font,
&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,
};
let bytes = postprocess::compress(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);
}
}