Skip to main content

mdpdf_core/
render.rs

1use comrak::{parse_document, format_html_with_plugins, Arena, Options, Plugins};
2use comrak::nodes::AstNode;
3
4use mdpdf_theme::{Layout, Theme};
5
6use crate::directives;
7use crate::frontmatter::parse_frontmatter;
8use crate::highlight::SyntectHighlighter;
9
10/// Result of rendering markdown to HTML.
11pub struct RenderResult {
12    pub html: String,
13    pub layout: Layout,
14}
15
16/// Render a markdown source string to a complete HTML document.
17///
18/// Pipeline:
19///   1. Extract frontmatter (title, subtitle, mode, toc)
20///   2. Parse body into comrak AST
21///   3. Walk AST — transform directive HTML comments into layout HTML
22///   4. Render AST to HTML with syntax highlighting
23///   5. Wrap in themed CSS + header
24pub fn render_to_html(src: &str, theme: Theme, dense: bool) -> RenderResult {
25    let (fm, body) = parse_frontmatter(src);
26    let layout = if dense || fm.is_dense() {
27        Layout::Dense
28    } else {
29        Layout::Normal
30    };
31
32    let highlighter = SyntectHighlighter::new(theme.syntect_theme());
33
34    let mut options = Options::default();
35    options.extension.table = true;
36    options.extension.strikethrough = true;
37    options.extension.autolink = true;
38    options.render.unsafe_ = true; // allow raw HTML passthrough
39
40    let mut plugins = Plugins::default();
41    plugins.render.codefence_syntax_highlighter = Some(&highlighter);
42
43    // Parse → transform directives → render
44    let arena = Arena::new();
45    let root = parse_document(&arena, body, &options);
46    directives::transform(root);
47    let rendered = render_ast(root, &options, &plugins);
48
49    let css = mdpdf_theme::generate_css(&theme, &layout);
50
51    // Header from frontmatter
52    let h1_font_size = match layout {
53        Layout::Dense => "18pt",
54        Layout::Normal => "22pt",
55    };
56    let header_html = match &fm.title {
57        Some(title) => {
58            let subtitle = fm.subtitle.as_deref().map(|s| {
59                format!("<div class=\"subtitle\">{s}</div>")
60            }).unwrap_or_default();
61            let date = fm.date.as_deref().map(|d| {
62                format!("<div class=\"date\">{d}</div>")
63            }).unwrap_or_default();
64            format!(
65                r#"<div class="doc-header">
66                    <h1 style="border:none;margin:0;padding:0;font-size:{h1_font_size};color:var(--header-fg)">{title}</h1>
67                    {subtitle}
68                    {date}
69                </div>"#,
70            )
71        }
72        None => String::new(),
73    };
74
75    let html = format!(
76        r#"<!DOCTYPE html>
77<html>
78<head>
79  <meta charset="utf-8">
80  <style>{css}</style>
81</head>
82<body>
83  {header_html}
84  {rendered}
85</body>
86</html>"#,
87    );
88
89    RenderResult { html, layout }
90}
91
92fn render_ast<'a>(root: &'a AstNode<'a>, options: &Options, plugins: &Plugins<'_>) -> String {
93    let mut output = Vec::new();
94    format_html_with_plugins(root, options, &mut output, plugins)
95        .expect("HTML rendering failed");
96    String::from_utf8(output).expect("HTML output was not valid UTF-8")
97}