papr-pdf 0.1.0

A fast, keyboard-first CLI tool to compile Markdown, LaTeX, and plain text files into publication-grade PDFs.
//! Markdown compilation engine with KaTeX math and syntax-highlighting.

use crate::cli::Theme;
use crate::engine::html_to_pdf::render_html_to_pdf;
use crate::theme::get_theme_css;
use anyhow::{Context, Result};
use pulldown_cmark::{html, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use std::fs;
use std::path::Path;
use syntect::highlighting::ThemeSet;
use syntect::html::highlighted_html_for_string;
use syntect::parsing::SyntaxSet;

/// Compiles a Markdown file into a publication-grade PDF.
pub fn compile_markdown(input_path: &Path, output_pdf: &Path, theme: Theme) -> Result<()> {
    let content = fs::read_to_string(input_path)
        .with_context(|| format!("Failed to read markdown file: {}", input_path.display()))?;

    let abs_input = fs::canonicalize(input_path)
        .with_context(|| format!("Failed to canonicalize markdown path: {}", input_path.display()))?;

    let parent_dir = abs_input
        .parent()
        .ok_or_else(|| anyhow::anyhow!("Input file has no parent directory"))?;

    let file_stem = abs_input
        .file_stem()
        .map(|s| s.to_string_lossy())
        .unwrap_or_else(|| "document".into());

    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_FOOTNOTES);
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_TASKLISTS);
    options.insert(Options::ENABLE_SMART_PUNCTUATION);
    options.insert(Options::ENABLE_MATH);

    let parser = Parser::new_ext(&content, options);

    let syntax_set = SyntaxSet::load_defaults_newlines();
    let theme_set = ThemeSet::load_defaults();
    let syntect_theme = match theme {
        Theme::Dark => &theme_set.themes["base16-eighties.dark"],
        _ => &theme_set.themes["InspiredGitHub"],
    };

    let mut events = Vec::new();
    let mut in_code_block = false;
    let mut code_lang = String::new();
    let mut code_content = String::new();

    for event in parser {
        match event {
            Event::Start(Tag::CodeBlock(ref kind)) => {
                in_code_block = true;
                code_content.clear();
                code_lang = match kind {
                    CodeBlockKind::Fenced(lang) => lang.to_string(),
                    CodeBlockKind::Indented => String::new(),
                };
            }
            Event::End(TagEnd::CodeBlock) => {
                in_code_block = false;
                let syntax = syntax_set
                    .find_syntax_by_token(&code_lang)
                    .unwrap_or_else(|| syntax_set.find_syntax_plain_text());

                match highlighted_html_for_string(
                    &code_content,
                    &syntax_set,
                    syntax,
                    syntect_theme,
                ) {
                    Ok(highlighted) => {
                        events.push(Event::Html(highlighted.into()));
                    }
                    Err(_) => {
                        // Fallback to basic pre/code
                        let escaped = html_escape(&code_content);
                        let html = format!("<pre><code>{}</code></pre>", escaped);
                        events.push(Event::Html(html.into()));
                    }
                }
            }
            Event::Text(text) if in_code_block => {
                code_content.push_str(&text);
            }
            other => {
                events.push(other);
            }
        }
    }

    let mut body_html = String::new();
    html::push_html(&mut body_html, events.into_iter());

    let css = get_theme_css(theme);

    let full_html = format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{}</title>
    <!-- KaTeX for math rendering -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
    <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
        onload="renderMathInElement(document.body, {{
            delimiters: [
                {{left: '$$', right: '$$', display: true}},
                {{left: '$', right: '$', display: false}},
                {{left: '\\(', right: '\\)', display: false}},
                {{left: '\\[', right: '\\]', display: true}}
            ],
            throwOnError: false
        }});"></script>
    <style>
    {}
    </style>
</head>
<body>
    <article class="markdown-body">
    {}
    </article>
</body>
</html>"#,
        file_stem, css, body_html
    );

    // Write temp HTML in parent_dir so relative images/assets resolve directly!
    let temp_html_path = parent_dir.join(format!(".{}_papr_temp.html", file_stem));
    fs::write(&temp_html_path, full_html)?;

    let result = render_html_to_pdf(&temp_html_path, output_pdf);

    // Always clean up temp HTML
    let _ = fs::remove_file(temp_html_path);

    result
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}