Add PDF export feature with print CSS
This commit is contained in:
195
src/main.rs
195
src/main.rs
@@ -20,6 +20,8 @@ enum Commands {
|
|||||||
file: String,
|
file: String,
|
||||||
#[arg(short, long, help = "Output file path")]
|
#[arg(short, long, help = "Output file path")]
|
||||||
output: Option<String>,
|
output: Option<String>,
|
||||||
|
#[arg(short = 'p', long, help = "Export as PDF")]
|
||||||
|
pdf: bool,
|
||||||
},
|
},
|
||||||
Batch {
|
Batch {
|
||||||
#[arg(help = "Directory containing markdown files")]
|
#[arg(help = "Directory containing markdown files")]
|
||||||
@@ -43,6 +45,14 @@ enum Commands {
|
|||||||
#[arg(short = 'e', long, default_value = "700")]
|
#[arg(short = 'e', long, default_value = "700")]
|
||||||
height: u32,
|
height: u32,
|
||||||
},
|
},
|
||||||
|
Export {
|
||||||
|
#[arg(help = "Markdown file to export")]
|
||||||
|
file: String,
|
||||||
|
#[arg(short, long, help = "Output file path")]
|
||||||
|
output: String,
|
||||||
|
#[arg(short, long, default_value = "pdf", value_parser = ["pdf", "html"])]
|
||||||
|
format: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_markdown(content: &str) -> String {
|
fn render_markdown(content: &str) -> String {
|
||||||
@@ -247,6 +257,163 @@ fn wrap_in_html(title: &str, content: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wrap_for_pdf(title: &str, content: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{}</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
|
<style>
|
||||||
|
@page {{
|
||||||
|
margin: 20mm;
|
||||||
|
size: A4;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
font-size: 12pt;
|
||||||
|
}}
|
||||||
|
h1, h2, h3, h4, h5, h6 {{
|
||||||
|
page-break-after: avoid;
|
||||||
|
}}
|
||||||
|
pre {{
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
font-size: 10pt;
|
||||||
|
}}
|
||||||
|
code {{
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||||
|
font-size: 10pt;
|
||||||
|
}}
|
||||||
|
pre code {{
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}}
|
||||||
|
blockquote {{
|
||||||
|
border-left: 3px solid #ddd;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #666;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #f4f4f4;
|
||||||
|
}}
|
||||||
|
img {{
|
||||||
|
max-width: 100%;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}}
|
||||||
|
.mermaid {{
|
||||||
|
background: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}}
|
||||||
|
.mermaid svg {{
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}}
|
||||||
|
.mermaid-download {{
|
||||||
|
display: none;
|
||||||
|
}}
|
||||||
|
@media print {{
|
||||||
|
body {{
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}}
|
||||||
|
.mermaid svg {{
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{}
|
||||||
|
<script>
|
||||||
|
async function renderMermaidDiagrams() {{
|
||||||
|
const mermaidDivs = document.querySelectorAll('.mermaid');
|
||||||
|
for (let i = 0; i < mermaidDivs.length; i++) {{
|
||||||
|
const div = mermaidDivs[i];
|
||||||
|
const code = div.textContent.trim();
|
||||||
|
const id = 'mermaid-' + i;
|
||||||
|
try {{
|
||||||
|
const {{ svg }} = await mermaid.render(id, code);
|
||||||
|
div.innerHTML = svg;
|
||||||
|
}} catch (err) {{
|
||||||
|
console.error('Mermaid render error:', err);
|
||||||
|
div.innerHTML = '<pre style="text-align:left;color:red;">Error: ' + err.message + '</pre>';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
setTimeout(function() {{ window.print(); }}, 1500);
|
||||||
|
}}
|
||||||
|
mermaid.initialize({{ startOnLoad: false }});
|
||||||
|
renderMermaidDiagrams();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
title, content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_pdf(file_path: &str, output: Option<String>) -> Result<()> {
|
||||||
|
let path = Path::new(file_path);
|
||||||
|
let content = read_file(path)?;
|
||||||
|
let html = render_markdown(&content);
|
||||||
|
let title = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("Markdown");
|
||||||
|
|
||||||
|
let pdf_html = wrap_for_pdf(title, &html);
|
||||||
|
|
||||||
|
let out_path = if let Some(o) = output {
|
||||||
|
o
|
||||||
|
} else {
|
||||||
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
let out_dir = home.join("docs/pdf");
|
||||||
|
std::fs::create_dir_all(&out_dir)?;
|
||||||
|
out_dir
|
||||||
|
.join(format!("{}.html", title))
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::write(&out_path, &pdf_html)
|
||||||
|
.with_context(|| format!("Failed to write to {}", out_path))?;
|
||||||
|
|
||||||
|
println!("PDF export file: {}", out_path);
|
||||||
|
println!("Open this file in browser and use File > Print > Save as PDF");
|
||||||
|
|
||||||
|
open::that(&out_path).context("Failed to open file in browser")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_file(path: &Path) -> Result<String> {
|
fn read_file(path: &Path) -> Result<String> {
|
||||||
std::fs::read_to_string(path)
|
std::fs::read_to_string(path)
|
||||||
.with_context(|| format!("Failed to read file: {}", path.display()))
|
.with_context(|| format!("Failed to read file: {}", path.display()))
|
||||||
@@ -467,10 +634,17 @@ fn main() {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Render { file, output } => {
|
Commands::Render { file, output, pdf } => {
|
||||||
if let Err(e) = process_file(&file, output) {
|
if pdf {
|
||||||
eprintln!("Error: {}", e);
|
if let Err(e) = export_pdf(&file, output) {
|
||||||
std::process::exit(1);
|
eprintln!("Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Err(e) = process_file(&file, output) {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Batch { directory, output } => {
|
Commands::Batch { directory, output } => {
|
||||||
@@ -503,5 +677,18 @@ fn main() {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Commands::Export { file, output, format } => {
|
||||||
|
if format == "pdf" {
|
||||||
|
if let Err(e) = export_pdf(&file, Some(output)) {
|
||||||
|
eprintln!("Export error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Err(e) = process_file(&file, Some(output)) {
|
||||||
|
eprintln!("Export error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user