Add terminal, Pan mode, zoom controls and shell commands

This commit is contained in:
accusys
2026-03-18 23:57:19 +08:00
parent f1463cd135
commit 8d39869e39
3 changed files with 797 additions and 33 deletions

View File

@@ -9,6 +9,8 @@ clap = { version = "4.5", features = ["derive"] }
anyhow = "1.0"
dirs = "5"
open = "5"
tao = "0.30"
wry = "0.54"
[[bin]]
name = "md_reader"

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# MD Reader
Markdown 文件閱讀器,支援 Mermaid 圖表、PDF 匯出、原生視窗預覽。
## 安裝
```bash
cargo build --release
```
## 功能
### 1. Markdown 轉 HTML
```bash
./target/release/md_reader render docs/example.md
# 輸出到 ~/docs/html/example.html
```
### 2. 原生視窗預覽
```bash
./target/release/md_reader preview docs/example.md
```
### 3. 批次轉換
```bash
./target/release/md_reader batch ./docs
```
### 4. PDF 匯出
```bash
./target/release/md_reader export docs/example.md -o output.pdf
# 或使用 render -p
./target/release/md_reader render docs/example.md -p
```
### 5. HTTP 伺服器
```bash
./target/release/md_reader server --port 8080 --path ./docs
```
## 視窗功能
### 工具列按鈕
| 按鈕 | 功能 |
|------|------|
| Print / Save as PDF | 列印或儲存為 PDF |
| Download All SVGs | 下載所有 Mermaid 圖表為 SVG |
| Pan | 滑鼠拖曳平移內容 |
| / + | 縮小 / 放大 |
| Reset | 重置縮放為 100% |
### 終端機命令
`/` 開啟終端輸入框。
| 命令 | 功能 |
|------|------|
| `/help` | 顯示幫助訊息 |
| `/clear` | 清除終端輸出 |
| `/print` | 開啟列印對話框 |
| `/svg` | 下載所有 SVG 圖表 |
| `/reload` | 重新載入頁面 |
| `/zoom [N]` | 設定縮放 (50-200%) |
| `/zoom in``/zoom +` | 放大 10% |
| `/zoom out``/zoom -` | 縮小 10% |
| `/zoom reset``/zoom 0` | 重置為 100% |
### Shell 命令
直接輸入 shell 命令(不帶 `/`)執行:
```bash
ls # 列出檔案
pwd # 顯示目前目錄
cd <dir> # 切換目錄
cat <file> # 顯示檔案內容
```
### Pan 模式
1. 點擊工具列的 `Pan` 按鈕
2. 按住滑鼠左鍵拖曳移動內容
3. 再次點擊 `Pan` 按鈕關閉
### 快捷鍵
| 按鍵 | 功能 |
|------|------|
| `/` | 開啟終端輸入框 |
| `Esc` | 關閉終端輸入框 |
## Mermaid 圖表支援
在 Markdown 中使用 ```mermaid 程式碼塊:
````markdown
```mermaid
graph TD
A[Start] --> B[End]
```
````
支援的圖表類型:
- Flowchart (流程圖)
- Sequence Diagram (序列圖)
- Class Diagram (類別圖)
- State Diagram (狀態圖)
- Entity Relationship Diagram (ER 圖)
- Gantt Chart (甘特圖)
- Pie Chart (圓餅圖)
## 專案結構
```
md_reader/
├── Cargo.toml
├── src/
│ └── main.rs # 主要程式碼
└── docs/
└── mermaid.md # Mermaid 範例文件
```
## 依賴
- Rust 2021 edition
- tao (視窗管理)
- wry (WebView)
- pulldown-cmark (Markdown 解析)
- clap (命令列參數)

View File

@@ -20,6 +20,8 @@ enum Commands {
file: String,
#[arg(short, long, help = "Output file path")]
output: Option<String>,
#[arg(short = 'p', long, help = "Export as PDF")]
pdf: bool,
},
Batch {
#[arg(help = "Directory containing markdown files")]
@@ -43,6 +45,14 @@ enum Commands {
#[arg(short = 'e', long, default_value = "700")]
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 {
@@ -121,13 +131,23 @@ fn wrap_in_html(title: &str, content: &str) -> String {
<title>{}</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* {{ box-sizing: border-box; }}
html, body {{ margin: 0; padding: 0; height: 100%; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
max-width: 100%;
padding: 20px;
padding-bottom: 0;
line-height: 1.6;
color: #333;
overflow-y: auto;
height: calc(100% - 100px);
}}
.content {{
max-width: 800px;
margin: 0 auto;
height: 100%;
overflow-y: auto;
}}
pre {{
background: #f4f4f4;
@@ -183,6 +203,8 @@ fn wrap_in_html(title: &str, content: &str) -> String {
.mermaid svg {{
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}}
.mermaid-download {{
position: absolute;
@@ -204,20 +226,172 @@ fn wrap_in_html(title: &str, content: &str) -> String {
.mermaid-download:hover {{
background: #0056b3;
}}
.toolbar {{
position: fixed;
top: 0;
left: 0;
right: 0;
background: #2c3e50;
color: white;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
z-index: 1000;
}}
.toolbar h1 {{
margin: 0;
font-size: 16px;
font-weight: 500;
}}
.toolbar button {{
background: #3498db;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}}
.toolbar button:hover {{
background: #2980b9;
}}
.toolbar .zoom-controls {{
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}}
.toolbar .zoom-level {{
font-size: 12px;
color: #ccc;
min-width: 45px;
text-align: center;
}}
body {{ padding-top: 50px; padding-bottom: 0; }}
.content-wrapper {{
height: calc(100% - 100px);
overflow-y: auto;
transform-origin: top center;
}}
.content {{ max-width: 800px; margin: 0 auto; }}
.content-inner {{
max-width: 800px;
margin: 0 auto;
transform-origin: top center;
}}
.content-wrapper.zoomed {{
transform-origin: top center;
}}
.terminal {{
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 100px;
background: #1e1e1e;
border-top: 1px solid #333;
display: flex;
flex-direction: column;
z-index: 1000;
}}
.terminal-output {{
flex: 1;
overflow-y: auto;
padding: 8px 12px;
font-family: "SF Mono", Monaco, Consolas, monospace;
font-size: 12px;
color: #ccc;
}}
.terminal-output .error {{ color: #f48771; }}
.terminal-output .success {{ color: #89d185; }}
.terminal-input-row {{
display: flex;
align-items: center;
padding: 6px 12px;
background: #252526;
border-top: 1px solid #333;
}}
.terminal-prompt {{
color: #007acc;
margin-right: 8px;
font-family: "SF Mono", Monaco, Consolas, monospace;
font-size: 12px;
}}
.terminal-hint {{
position: fixed;
bottom: 105px;
right: 16px;
color: #666;
font-size: 11px;
background: #1e1e1e;
padding: 4px 8px;
border-radius: 4px;
}}
.terminal-input {{
flex: 1;
background: transparent;
border: none;
color: #ccc;
font-family: "SF Mono", Monaco, Consolas, monospace;
font-size: 12px;
outline: none;
}}
.terminal-input:focus {{
background: rgba(255,255,255,0.05);
}}
.pan-mode {{ cursor: grab; }}
.pan-mode.panning {{ cursor: grabbing; }}
.pan-active {{ cursor: grab; }}
.pan-active.panning {{ cursor: grabbing; }}
.pan-button {{
background: #6c757d !important;
}}
.pan-button.active {{
background: #495057 !important;
}}
</style>
</head>
<body>
<div class="toolbar">
<h1>{}</h1>
<button onclick="window.print()">Print / Save as PDF</button>
<button onclick="downloadAllSvg()">Download All SVGs</button>
<button onclick="togglePan()" id="pan-btn" class="pan-button">Pan</button>
<div class="zoom-controls">
<button onclick="zoomOut()"></button>
<span class="zoom-level" id="zoom-level">100%</span>
<button onclick="zoomIn()">+</button>
<button onclick="resetZoom()">Reset</button>
</div>
</div>
<div class="content-wrapper" id="content-wrapper">
<div class="content-inner" id="content-inner" style="padding: 20px;">
{}
</div>
</div>
<div class="terminal-hint">Press / to enter command mode</div>
<div class="terminal">
<div class="terminal-output" id="terminal-output"></div>
<div class="terminal-input-row">
<span class="terminal-prompt">&gt;</span>
<input type="text" class="terminal-input" id="terminal-input" placeholder="Enter command..." autocomplete="off" spellcheck="false">
</div>
</div>
<script>
let svgData = [];
async function renderMermaidDiagrams() {{
const mermaidDivs = document.querySelectorAll('.mermaid');
svgData = [];
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 + '<button class="mermaid-download" onclick="downloadSvg(this)" data-svg="' + encodeURIComponent(svg) + '">Download SVG</button>';
svgData.push({{ id: i, svg: svg }});
const scaledSvg = svg.replace(/<svg/, '<svg style="max-width:100%;height:auto;"');
div.innerHTML = scaledSvg + '<button class="mermaid-download" onclick="downloadSingleSvg(' + i + ')">Download SVG</button>';
}} catch (err) {{
console.error('Mermaid render error:', err);
div.innerHTML = '<pre style="text-align:left;color:red;">Error: ' + err.message + '</pre>';
@@ -225,25 +399,205 @@ fn wrap_in_html(title: &str, content: &str) -> String {
}}
}}
function downloadSvg(btn) {{
const svg = decodeURIComponent(btn.getAttribute('data-svg'));
const blob = new Blob([svg], {{ type: 'image/svg+xml' }});
function downloadSingleSvg(index) {{
const data = svgData.find(d => d.id === index);
if (data) {{
const blob = new Blob([data.svg], {{ type: 'image/svg+xml' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'diagram-' + Date.now() + '.svg';
document.body.appendChild(a);
a.download = 'diagram-' + index + '-' + Date.now() + '.svg';
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
}}
function downloadAllSvg() {{
svgData.forEach((data, index) => {{
setTimeout(() => {{
const blob = new Blob([data.svg], {{ type: 'image/svg+xml' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'diagram-' + index + '-' + Date.now() + '.svg';
a.click();
URL.revokeObjectURL(url);
}}, index * 200);
}});
}}
function appendOutput(text, type = '') {{
const output = document.getElementById('terminal-output');
const line = document.createElement('div');
line.className = type;
line.textContent = text;
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}}
let currentZoom = 100;
function zoomIn() {{
currentZoom = Math.min(currentZoom + 10, 200);
applyZoom();
}}
function zoomOut() {{
currentZoom = Math.max(currentZoom - 10, 50);
applyZoom();
}}
function resetZoom() {{
currentZoom = 100;
applyZoom();
}}
function applyZoom() {{
const wrapper = document.getElementById('content-wrapper');
const scale = currentZoom / 100;
wrapper.style.transform = 'scale(' + scale + ')';
wrapper.style.height = (100 / scale) + '%';
document.getElementById('zoom-level').textContent = currentZoom + '%';
}}
function executeCommand(cmd) {{
appendOutput('> ' + cmd);
const cmdName = cmd.replace(/^\/+/, '');
const cmdParts = cmdName.split(' ');
const baseCmd = cmdParts[0];
const subCmd = cmdParts.slice(1).join(' ');
if (baseCmd === 'help') {{
appendOutput('Commands (prefix with /):');
appendOutput(' /help - Show this help message');
appendOutput(' /clear - Clear terminal output');
appendOutput(' /print - Print / Save as PDF');
appendOutput(' /svg - Download all SVG diagrams');
appendOutput(' /reload - Reload page');
appendOutput(' /zoom - Show current zoom level');
appendOutput(' /zoom in | + - Zoom in (max 200%)');
appendOutput(' /zoom out | - - Zoom out (min 50%)');
appendOutput(' /zoom <N> - Set zoom to N% (50-200)');
appendOutput(' /zoom reset | 0 - Reset zoom to 100%');
appendOutput(' /term - Activate terminal mode');
appendOutput(' /zoom - Show current zoom level');
appendOutput(' /zoom in | + - Zoom in (max 200%)');
appendOutput(' /zoom out | - - Zoom out (min 50%)');
appendOutput(' /zoom <N> - Set zoom to N% (50-200)');
appendOutput(' /zoom reset | 0 - Reset zoom to 100%');
appendOutput('');
appendOutput('Shell commands (without /): ls, pwd, cd, cat, etc.');
}} else if (baseCmd === 'clear') {{
document.getElementById('terminal-output').innerHTML = '';
}} else if (baseCmd === 'print') {{
window.print();
appendOutput('Opening print dialog...', 'success');
}} else if (baseCmd === 'svg') {{
downloadAllSvg();
appendOutput('Downloading SVGs...', 'success');
}} else if (baseCmd === 'reload') {{
window.location.reload();
}} else if (baseCmd === 'zoom') {{
if (subCmd === 'in' || subCmd === '+') {{
zoomIn();
appendOutput('Zoom: ' + currentZoom + '%', 'success');
}} else if (subCmd === 'out' || subCmd === '-') {{
zoomOut();
appendOutput('Zoom: ' + currentZoom + '%', 'success');
}} else if (subCmd === 'reset' || subCmd === '0') {{
resetZoom();
appendOutput('Zoom reset to 100%', 'success');
}} else {{
const level = parseInt(subCmd);
if (!isNaN(level) && level >= 50 && level <= 200) {{
currentZoom = level;
applyZoom();
appendOutput('Zoom: ' + currentZoom + '%', 'success');
}} else {{
appendOutput('Usage: /zoom [in|out|+|-|reset|0|<50-200>]', 'error');
}}
}}
}} else if (baseCmd === '' || cmd.trim() === '') {{
}} else if (!cmd.startsWith('/')) {{
if (window.execShell) {{
window.execShell(cmd);
}} else {{
appendOutput('Shell execution not available', 'error');
}}
}} else {{
appendOutput('Unknown command: ' + baseCmd + '. Type /help for available commands.', 'error');
}}
}}
document.getElementById('terminal-input').addEventListener('keydown', function(e) {{
if (e.key === 'Escape') {{
this.blur();
}}
if (e.key === 'Enter' && this.value.trim() !== '') {{
const cmd = this.value.trim();
this.value = '';
executeCommand(cmd);
}}
}});
window.addEventListener('keydown', function(e) {{
if (e.key === '/' && document.activeElement.id !== 'terminal-input' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {{
e.preventDefault();
document.getElementById('terminal-input').focus();
}}
}});
let panState = {{ isEnabled: false, isPanning: false, startX: 0, startY: 0, offsetX: 0, offsetY: 0 }};
window.togglePan = function() {{
panState.isEnabled = !panState.isEnabled;
const btn = document.getElementById('pan-btn');
if (panState.isEnabled) {{
btn.classList.add('active');
appendOutput('Pan mode ON - drag to move', 'success');
}} else {{
btn.classList.remove('active');
panState.offsetX = 0;
panState.offsetY = 0;
document.getElementById('content-inner').style.transform = 'translate(0px, 0px)';
appendOutput('Pan mode OFF', 'success');
}}
}};
document.addEventListener('mousedown', function(e) {{
if (panState.isEnabled && e.target.tagName !== 'BUTTON' && e.target.tagName !== 'INPUT' && e.target.id !== 'terminal-input') {{
panState.isPanning = true;
panState.startX = e.clientX - panState.offsetX;
panState.startY = e.clientY - panState.offsetY;
e.preventDefault();
}}
}});
document.addEventListener('mousemove', function(e) {{
if (panState.isPanning) {{
panState.offsetX = e.clientX - panState.startX;
panState.offsetY = e.clientY - panState.startY;
document.getElementById('content-inner').style.transform = 'translate(' + panState.offsetX + 'px, ' + panState.offsetY + 'px)';
}}
}});
document.addEventListener('mouseup', function() {{
panState.isPanning = false;
}});
document.addEventListener('mouseleave', function() {{
panState.isPanning = false;
}});
window.terminalExecute = executeCommand;
mermaid.initialize({{ startOnLoad: false }});
renderMermaidDiagrams();
appendOutput('Type /help for available commands. Press / to enter commands.', 'success');
</script>
</body>
</html>"#,
title, content
title, title, content
)
}
@@ -439,27 +793,276 @@ fn list_directory(path: &Path) -> String {
files.join("\n")
}
fn preview_markdown(file_path: &str, title: &str, _width: u32, _height: u32) -> Result<()> {
fn preview_markdown(file_path: &str, title: &str, width: u32, height: u32) -> Result<()> {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::process;
use std::thread;
use std::time::Duration;
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tao::window::WindowBuilder;
use wry::WebViewBuilder;
let path = Path::new(file_path);
let content = read_file(path)?;
let html_content = render_markdown(&content);
let full_html = wrap_in_html(title, &html_content);
let port: u16 = (9000 + (process::id() % 1000)) as u16;
let port_clone = port;
thread::spawn(move || {
if let Ok(listener) = TcpListener::bind(format!("127.0.0.1:{}", port_clone)) {
for stream in listener.incoming() {
if let Ok(mut stream) = stream {
let mut buffer = [0; 4096];
if let Ok(_) = stream.read(&mut buffer) {
let request = String::from_utf8_lossy(&buffer);
if request.starts_with("POST /cmd ") {
let cmd = request.lines().last().unwrap_or("").trim();
let cmd_trimmed = cmd.trim();
if cmd_trimmed.is_empty() {
continue;
}
let parts: Vec<&str> = cmd_trimmed.split_whitespace().collect();
let output = if parts[0] == "cd" {
if let Some(dir) = parts.get(1) {
match std::env::set_current_dir(dir) {
Ok(_) => format!("Changed to: {}", dir),
Err(e) => format!("cd: {}: {}", dir, e),
}
} else {
"cd: missing directory".to_string()
}
} else {
let result = std::process::Command::new(parts[0])
.args(&parts[1..])
.output();
match result {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if stdout.is_empty() && !stderr.is_empty() {
stderr.to_string()
} else if stdout.is_empty() && stderr.is_empty() {
"(done)".to_string()
} else {
stdout.to_string()
}
}
Err(e) => format!("Error: {}: {}", parts[0], e),
}
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}",
output.len(),
output
);
let _ = stream.write_all(response.as_bytes());
} else {
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
let _ = stream.write_all(response.as_bytes());
}
}
}
}
}
});
thread::sleep(Duration::from_millis(200));
let full_html = full_html.replace(
"// __TERMINAL_EXEC__PLACEHOLDER__",
&format!(
r#"
window.execShell = function(cmd) {{
var xhr = new XMLHttpRequest();
xhr.open('POST', 'http://127.0.0.1:{}/cmd', false);
xhr.send(cmd);
if (xhr.responseText) {{
appendOutput(xhr.responseText);
}}
}};
"#,
port
),
);
let event_loop = EventLoopBuilder::new().build();
let window = WindowBuilder::new()
.with_title(title)
.with_inner_size(tao::dpi::LogicalSize::new(width as f64, height as f64))
.build(&event_loop)?;
let _webview = WebViewBuilder::new().with_html(&full_html).build(&window)?;
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
if let Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} = event
{
*control_flow = ControlFlow::Exit;
}
});
}
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 full_html = wrap_in_html(title, &html);
let title = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Markdown");
let temp_dir = std::env::temp_dir();
let safe_title: String = title
.replace(' ', "_")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
.collect();
let temp_file = temp_dir.join(format!("{}.html", safe_title));
println!("Temp file: {}", temp_file.display());
let pdf_html = wrap_for_pdf(title, &html);
std::fs::write(&temp_file, &full_html)
.with_context(|| format!("Failed to write temp file: {}", temp_file.display()))?;
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()
};
open::that(&temp_file).with_context(|| format!("Failed to open file in browser"))?;
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")?;
println!("Opening preview in browser... (close browser window to exit)");
Ok(())
}
@@ -467,12 +1070,19 @@ fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Render { file, output } => {
Commands::Render { file, output, pdf } => {
if pdf {
if let Err(e) = export_pdf(&file, output) {
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 } => {
if let Err(e) = batch_process(&directory, output) {
eprintln!("Error: {}", e);
@@ -503,5 +1113,22 @@ fn main() {
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);
}
}
}
}
}