From f1463cd1359cde355f935e78b41d8e8f4c37670e Mon Sep 17 00:00:00 2001 From: accusys Date: Wed, 18 Mar 2026 21:28:13 +0800 Subject: [PATCH] Add Mermaid diagram support with SVG export - Add mermaid code block detection and rendering - Integrate Mermaid.js v10 via CDN for diagram rendering - Add SVG export with download button on hover - Add preview command to open markdown in browser - Add documentation for all supported diagram types --- Cargo.toml | 3 +- docs/mermaid.md | 137 ++++++++++++++++++++++++++ src/main.rs | 253 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 docs/mermaid.md diff --git a/Cargo.toml b/Cargo.toml index cf6afa5..2d21e9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,8 @@ edition = "2021" pulldown-cmark = { version = "0.10", features = ["html"] } clap = { version = "4.5", features = ["derive"] } anyhow = "1.0" -syntect = "5" +dirs = "5" +open = "5" [[bin]] name = "md_reader" diff --git a/docs/mermaid.md b/docs/mermaid.md new file mode 100644 index 0000000..c8e473e --- /dev/null +++ b/docs/mermaid.md @@ -0,0 +1,137 @@ +# Mermaid 圖表功能 + +md_reader 支援在 Markdown 中使用 Mermaid 語法繪製各類圖表。 + +## 使用方式 + +在 Markdown 中使用 `mermaid` 程式碼區塊: + +```mermaid +graph TD + A[開始] --> B{結束} +``` + +## 預覽功能 + +使用 `preview` 命令在瀏覽器中預覽 Markdown 文件: + +```bash +# 基本用法 +md_reader preview docs/mermaid.md + +# 自訂標題和視窗大小 +md_reader preview docs/mermaid.md --title "My Document" -w 1200 +``` + +> 注意: 視窗大小參數 (-w, -e) 目前用於說明用途,實際大小由瀏覽器視窗決定。 + +命令列選項: +- `file`: 要預覽的 Markdown 文件路徑 +- `-t, --title`: 視窗標題(預設:檔案名稱) +- `-w, --width`: 視窗寬度(預設:900) +- `-h, --height`: 視窗高度(預設:700) + +## 支援的圖表類型 + +### 流程圖 (Flowchart) + +```mermaid +graph TD + A[開始] --> B{判斷} + B -->|是| C[執行任務] + B -->|否| D[結束] + C --> D +``` + +### 時序圖 (Sequence Diagram) + +```mermaid +sequenceDiagram + 客戶->>+伺服器: 發送請求 + 伺服器->>-客戶: 回傳資料 +``` + +### 類圖 (Class Diagram) + +```mermaid +classDiagram + class Animal { + +String name + +makeSound() + } + class Dog { + +bark() + } + Animal <|-- Dog +``` + +### 狀態圖 (State Diagram) + +```mermaid +stateDiagram-v2 + [*] --> 待機 + 待機 --> 工作中: 開始任務 + 工作中 --> 完成: 任務結束 + 完成 --> [*] +``` + +### 甘特圖 (Gantt Chart) + +```mermaid +gantt + title 專案時程 + dateFormat YYYY-MM-DD + section 設計 + 設計階段: des1, 2024-01-01, 30d + section 開發 + 開發階段: des2, after des1, 60d + section 測試 + 測試階段: des3, after des2, 20d +``` + +### 餅圖 (Pie Chart) + +```mermaid +pie title 語言使用統計 + "Rust": 45 + "Python": 30 + "JavaScript": 25 +``` + +### 關聯圖 (Entity Relationship Diagram) + +```mermaid +erDiagram + USER ||--o{ ORDER : places + USER { + int id PK + string name + string email + } + ORDER ||--|{ ITEM : contains + ORDER { + int id PK + int user_id FK + date created + } +``` + +## 輸出範例 + +渲染後的 HTML 會自動載入 Mermaid.js,並在瀏覽器中呈現互動式圖表。 + +## SVG 匯出功能 + +Mermaid 圖表會自動渲染為 SVG 並嵌入 HTML 中。 + +**下載方式:** +1. 將滑鼠移到 Mermaid 圖表上 +2. 點擊右上角的藍色「Download SVG」按鈕 + +圖表會自動下載為 `diagram-[timestamp].svg` 檔案。 + +## 限制 + +- Mermaid 圖表需要在瀏覽器環境中渲染 +- 離線使用需自行配置本地 Mermaid.js +- 圖表會以 SVG 格式嵌入,方便向量圖形編輯 diff --git a/src/main.rs b/src/main.rs index a38280a..65e5929 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use clap::{Parser, Subcommand}; use pulldown_cmark::{html, Options, Parser as MarkdownParser}; use std::io::{Read, Write}; use std::net::TcpListener; -use std::path::Path; +use std::path::{Path, PathBuf}; #[derive(Parser)] #[command(name = "md_reader")] @@ -18,6 +18,14 @@ enum Commands { Render { #[arg(help = "Markdown file to render")] file: String, + #[arg(short, long, help = "Output file path")] + output: Option, + }, + Batch { + #[arg(help = "Directory containing markdown files")] + directory: String, + #[arg(short, long, help = "Output directory")] + output: Option, }, Server { #[arg(short, long, default_value = "8080")] @@ -25,6 +33,16 @@ enum Commands { #[arg(short, long, help = "Directory to serve")] path: Option, }, + Preview { + #[arg(help = "Markdown file to preview")] + file: String, + #[arg(short, long, default_value = "Preview")] + title: Option, + #[arg(short = 'w', long, default_value = "900")] + width: u32, + #[arg(short = 'e', long, default_value = "700")] + height: u32, + }, } fn render_markdown(content: &str) -> String { @@ -37,9 +55,62 @@ fn render_markdown(content: &str) -> String { let parser = MarkdownParser::new_ext(content, options); let mut html_output = String::new(); html::push_html(&mut html_output, parser); + let html_output = process_mermaid_blocks(&html_output); html_output } +fn process_mermaid_blocks(html: &str) -> String { + let mut result = String::new(); + let mut code_content = Vec::new(); + let mut skip_until_close = false; + + for line in html.lines() { + if line.contains("
 = line.split("
').skip(1).collect::>().join(">");
+                if !after_lang.is_empty() && !after_lang.contains("
") {
+                    code_content.push(after_lang.trim_end_matches("").to_string());
+                }
+            }
+            continue;
+        }
+
+        if skip_until_close {
+            if line.contains("") {
+                let clean = line
+                    .replace("", "")
+                    .replace("
", "") + .trim_end() + .to_string(); + if !clean.is_empty() { + code_content.push(clean); + } + skip_until_close = false; + result.push_str(&format!( + "
{}
", + code_content.join("\n").trim() + )); + code_content.clear(); + } else { + code_content.push(line.to_string()); + } + continue; + } + + result.push_str(line); + result.push('\n'); + } + + result +} + fn wrap_in_html(title: &str, content: &str) -> String { format!( r#" @@ -48,6 +119,7 @@ fn wrap_in_html(title: &str, content: &str) -> String { {} + {} + "#, title, content @@ -109,7 +252,7 @@ fn read_file(path: &Path) -> Result { .with_context(|| format!("Failed to read file: {}", path.display())) } -fn process_file(file_path: &str) -> Result<()> { +fn process_file(file_path: &str, output: Option) -> Result<()> { let path = Path::new(file_path); let content = read_file(path)?; @@ -118,7 +261,59 @@ fn process_file(file_path: &str) -> Result<()> { .file_stem() .and_then(|s| s.to_str()) .unwrap_or("Markdown"); - println!("{}", wrap_in_html(title, &html)); + let full_html = wrap_in_html(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/html"); + std::fs::create_dir_all(&out_dir)?; + out_dir + .join(format!("{}.html", title)) + .to_string_lossy() + .to_string() + }; + + std::fs::write(&out_path, &full_html) + .with_context(|| format!("Failed to write to {}", out_path))?; + println!("Saved to {}", out_path); + Ok(()) +} + +fn batch_process(input_dir: &str, output_dir: Option) -> Result<()> { + let out_dir = if let Some(o) = output_dir { + PathBuf::from(o) + } else { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join("docs/html") + }; + + std::fs::create_dir_all(&out_dir)?; + + let dir_path = Path::new(input_dir); + let mut count = 0; + + for entry in std::fs::read_dir(dir_path)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("md") { + 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 full_html = wrap_in_html(title, &html); + + let out_path = out_dir.join(format!("{}.html", title)); + std::fs::write(&out_path, &full_html)?; + println!("Saved: {}", out_path.display()); + count += 1; + } + } + + println!("\nConverted {} files to {}", count, out_dir.display()); Ok(()) } @@ -244,12 +439,42 @@ fn list_directory(path: &Path) -> String { files.join("\n") } +fn preview_markdown(file_path: &str, title: &str, _width: u32, _height: u32) -> 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 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()); + + std::fs::write(&temp_file, &full_html) + .with_context(|| format!("Failed to write temp file: {}", temp_file.display()))?; + + open::that(&temp_file).with_context(|| format!("Failed to open file in browser"))?; + + println!("Opening preview in browser... (close browser window to exit)"); + Ok(()) +} + fn main() { let cli = Cli::parse(); match cli.command { - Commands::Render { file } => { - if let Err(e) = process_file(&file) { + Commands::Render { file, output } => { + 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); std::process::exit(1); } @@ -260,5 +485,23 @@ fn main() { std::process::exit(1); } } + Commands::Preview { + file, + title, + width, + height, + } => { + let window_title = title.unwrap_or_else(|| { + Path::new(&file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Preview") + .to_string() + }); + if let Err(e) = preview_markdown(&file, &window_title, width, height) { + eprintln!("Preview error: {}", e); + std::process::exit(1); + } + } } }