diff --git a/Cargo.toml b/Cargo.toml index 2d21e9b..31caa17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fb90f1 --- /dev/null +++ b/README.md @@ -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 # 切換目錄 +cat # 顯示檔案內容 +``` + +### 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 (命令列參數) diff --git a/src/main.rs b/src/main.rs index 65e5929..48e48fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,8 @@ enum Commands { file: String, #[arg(short, long, help = "Output file path")] output: Option, + #[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 { {} +
+

{}

+ + + +
+ + 100% + + +
+
+
+
{} +
+
+
Press / to enter command mode
+
+
+
+ > + +
+
"#, - 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#" + + + + + {} + + + + +{} + + +"#, + title, content + ) +} + +fn export_pdf(file_path: &str, output: Option) -> 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,10 +1070,17 @@ fn main() { let cli = Cli::parse(); match cli.command { - Commands::Render { file, output } => { - if let Err(e) = process_file(&file, output) { - eprintln!("Error: {}", e); - std::process::exit(1); + 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 } => { @@ -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); + } + } + } } }