Skip to main content

dkp_gen_core/
render.rs

1//! Renders `human/handbook.md` into `handbook.pdf` and `handbook.epub`,
2//! per DKP spec §11.2. Both formats are produced in-process by pure-Rust
3//! crates (`markdown2pdf`, `epub-builder` + `pulldown-cmark`) — no
4//! external binaries (no pandoc/xelatex) are shelled out to, so `dkp`
5//! stays a single self-contained binary.
6
7use std::fs::File;
8use std::path::Path;
9
10use epub_builder::{EpubBuilder, EpubContent, ZipLibrary};
11
12use crate::error::GenResult;
13
14/// Bundled Unicode body font (Bitstream Vera-licensed, see
15/// `assets/DejaVuSans-LICENSE.txt`). The renderer's built-in fallback
16/// (Type 1 Helvetica) only covers WinAnsi/Latin-1, so em-dashes,
17/// non-breaking hyphens, smart quotes, etc. would render as `?` without
18/// an explicit Unicode-capable font. Embedding rather than probing the
19/// host's installed fonts keeps rendering deterministic across machines/CI.
20static DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
21
22#[derive(Debug, Default, Clone)]
23pub struct RenderReport {
24    pub epub_written: bool,
25    pub pdf_written: bool,
26    pub warnings: Vec<String>,
27}
28
29/// Renders `handbook_md` (the contents of `human/handbook.md`) to
30/// `handbook.pdf` and `handbook.epub` inside `human_dir`. A failure in
31/// one format is recorded in `RenderReport.warnings` rather than
32/// propagated, so it doesn't block the other format or the caller's
33/// generation/build step — these renderings are optional per spec.
34pub fn render_handbook_formats(handbook_md: &str, human_dir: &Path) -> GenResult<RenderReport> {
35    let mut report = RenderReport::default();
36
37    match render_pdf(handbook_md, &human_dir.join("handbook.pdf")) {
38        Ok(()) => report.pdf_written = true,
39        Err(e) => report.warnings.push(format!("handbook.pdf: {e}")),
40    }
41
42    match render_epub(handbook_md, &human_dir.join("handbook.epub")) {
43        Ok(()) => report.epub_written = true,
44        Err(e) => report.warnings.push(format!("handbook.epub: {e}")),
45    }
46
47    Ok(report)
48}
49
50fn render_pdf(markdown: &str, out: &Path) -> Result<(), String> {
51    let mut font_config = markdown2pdf::fonts::FontConfig::new();
52    font_config.default_font_source = Some(markdown2pdf::fonts::FontSource::bytes(DEJAVU_SANS));
53
54    markdown2pdf::parse_into_file(
55        markdown.to_string(),
56        out,
57        markdown2pdf::config::ConfigSource::Default,
58        Some(&font_config),
59    )
60    .map_err(|e| e.to_string())
61}
62
63fn render_epub(markdown: &str, out: &Path) -> Result<(), String> {
64    let mut html_body = String::new();
65    pulldown_cmark::html::push_html(&mut html_body, pulldown_cmark::Parser::new(markdown));
66    let xhtml = format!(
67        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
68         <!DOCTYPE html>\n\
69         <html xmlns=\"http://www.w3.org/1999/xhtml\">\n\
70         <head><title>Handbook</title></head>\n\
71         <body>\n{html_body}\n</body>\n</html>"
72    );
73
74    let zip = ZipLibrary::new().map_err(|e| e.to_string())?;
75    let mut builder = EpubBuilder::new(zip).map_err(|e| e.to_string())?;
76    builder
77        .metadata("title", "Handbook")
78        .map_err(|e| e.to_string())?;
79    builder
80        .add_content(EpubContent::new("handbook.xhtml", xhtml.as_bytes()).title("Handbook"))
81        .map_err(|e| e.to_string())?;
82
83    let mut file = File::create(out).map_err(|e| e.to_string())?;
84    builder.generate(&mut file).map_err(|e| e.to_string())?;
85    Ok(())
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use tempfile::TempDir;
92
93    #[test]
94    fn renders_both_formats_for_simple_markdown() {
95        let tmp = TempDir::new().unwrap();
96        let markdown = "# Handbook\n\nSome **bold** text and a list:\n\n- one\n- two\n";
97
98        let report = render_handbook_formats(markdown, tmp.path()).unwrap();
99
100        assert!(
101            report.warnings.is_empty(),
102            "warnings: {:?}",
103            report.warnings
104        );
105        assert!(report.pdf_written);
106        assert!(report.epub_written);
107
108        let pdf = tmp.path().join("handbook.pdf");
109        let epub = tmp.path().join("handbook.epub");
110        assert!(pdf.exists());
111        assert!(epub.exists());
112        assert!(std::fs::metadata(&pdf).unwrap().len() > 0);
113        assert!(std::fs::metadata(&epub).unwrap().len() > 0);
114    }
115
116    #[test]
117    fn renders_empty_markdown_without_error() {
118        let tmp = TempDir::new().unwrap();
119        let report = render_handbook_formats("", tmp.path()).unwrap();
120        assert!(
121            report.warnings.is_empty(),
122            "warnings: {:?}",
123            report.warnings
124        );
125        assert!(report.pdf_written);
126        assert!(report.epub_written);
127    }
128}