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
This commit is contained in:
@@ -7,7 +7,8 @@ edition = "2021"
|
|||||||
pulldown-cmark = { version = "0.10", features = ["html"] }
|
pulldown-cmark = { version = "0.10", features = ["html"] }
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
syntect = "5"
|
dirs = "5"
|
||||||
|
open = "5"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "md_reader"
|
name = "md_reader"
|
||||||
|
|||||||
137
docs/mermaid.md
Normal file
137
docs/mermaid.md
Normal file
@@ -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 格式嵌入,方便向量圖形編輯
|
||||||
253
src/main.rs
253
src/main.rs
@@ -3,7 +3,7 @@ use clap::{Parser, Subcommand};
|
|||||||
use pulldown_cmark::{html, Options, Parser as MarkdownParser};
|
use pulldown_cmark::{html, Options, Parser as MarkdownParser};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "md_reader")]
|
#[command(name = "md_reader")]
|
||||||
@@ -18,6 +18,14 @@ enum Commands {
|
|||||||
Render {
|
Render {
|
||||||
#[arg(help = "Markdown file to render")]
|
#[arg(help = "Markdown file to render")]
|
||||||
file: String,
|
file: String,
|
||||||
|
#[arg(short, long, help = "Output file path")]
|
||||||
|
output: Option<String>,
|
||||||
|
},
|
||||||
|
Batch {
|
||||||
|
#[arg(help = "Directory containing markdown files")]
|
||||||
|
directory: String,
|
||||||
|
#[arg(short, long, help = "Output directory")]
|
||||||
|
output: Option<String>,
|
||||||
},
|
},
|
||||||
Server {
|
Server {
|
||||||
#[arg(short, long, default_value = "8080")]
|
#[arg(short, long, default_value = "8080")]
|
||||||
@@ -25,6 +33,16 @@ enum Commands {
|
|||||||
#[arg(short, long, help = "Directory to serve")]
|
#[arg(short, long, help = "Directory to serve")]
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
},
|
},
|
||||||
|
Preview {
|
||||||
|
#[arg(help = "Markdown file to preview")]
|
||||||
|
file: String,
|
||||||
|
#[arg(short, long, default_value = "Preview")]
|
||||||
|
title: Option<String>,
|
||||||
|
#[arg(short = 'w', long, default_value = "900")]
|
||||||
|
width: u32,
|
||||||
|
#[arg(short = 'e', long, default_value = "700")]
|
||||||
|
height: u32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_markdown(content: &str) -> String {
|
fn render_markdown(content: &str) -> String {
|
||||||
@@ -37,9 +55,62 @@ fn render_markdown(content: &str) -> String {
|
|||||||
let parser = MarkdownParser::new_ext(content, options);
|
let parser = MarkdownParser::new_ext(content, options);
|
||||||
let mut html_output = String::new();
|
let mut html_output = String::new();
|
||||||
html::push_html(&mut html_output, parser);
|
html::push_html(&mut html_output, parser);
|
||||||
|
let html_output = process_mermaid_blocks(&html_output);
|
||||||
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("<pre><code")
|
||||||
|
&& (line.contains("mermaid") || line.contains("language-mermaid"))
|
||||||
|
{
|
||||||
|
skip_until_close = true;
|
||||||
|
let parts: Vec<&str> = line.split("<pre><code").collect();
|
||||||
|
if !parts[0].is_empty() {
|
||||||
|
result.push_str(parts[0]);
|
||||||
|
}
|
||||||
|
if let Some(rest) = parts.get(1) {
|
||||||
|
let after_lang: String = rest.split('>').skip(1).collect::<Vec<_>>().join(">");
|
||||||
|
if !after_lang.is_empty() && !after_lang.contains("<pre>") {
|
||||||
|
code_content.push(after_lang.trim_end_matches("</code>").to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if skip_until_close {
|
||||||
|
if line.contains("</code>") {
|
||||||
|
let clean = line
|
||||||
|
.replace("</code>", "")
|
||||||
|
.replace("</pre>", "")
|
||||||
|
.trim_end()
|
||||||
|
.to_string();
|
||||||
|
if !clean.is_empty() {
|
||||||
|
code_content.push(clean);
|
||||||
|
}
|
||||||
|
skip_until_close = false;
|
||||||
|
result.push_str(&format!(
|
||||||
|
"<div class=\"mermaid\">{}</div>",
|
||||||
|
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 {
|
fn wrap_in_html(title: &str, content: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"<!DOCTYPE html>
|
r#"<!DOCTYPE html>
|
||||||
@@ -48,6 +119,7 @@ fn wrap_in_html(title: &str, content: &str) -> String {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{}</title>
|
<title>{}</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {{
|
body {{
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
@@ -94,10 +166,81 @@ fn wrap_in_html(title: &str, content: &str) -> String {
|
|||||||
img {{
|
img {{
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}}
|
}}
|
||||||
|
.mermaid {{
|
||||||
|
background: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}}
|
||||||
|
.mermaid:hover {{
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}}
|
||||||
|
.mermaid svg {{
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}}
|
||||||
|
.mermaid-download {{
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}}
|
||||||
|
.mermaid:hover .mermaid-download {{
|
||||||
|
opacity: 1;
|
||||||
|
}}
|
||||||
|
.mermaid-download:hover {{
|
||||||
|
background: #0056b3;
|
||||||
|
}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 + '<button class="mermaid-download" onclick="downloadSvg(this)" data-svg="' + encodeURIComponent(svg) + '">Download SVG</button>';
|
||||||
|
}} catch (err) {{
|
||||||
|
console.error('Mermaid render error:', err);
|
||||||
|
div.innerHTML = '<pre style="text-align:left;color:red;">Error: ' + err.message + '</pre>';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
function downloadSvg(btn) {{
|
||||||
|
const svg = decodeURIComponent(btn.getAttribute('data-svg'));
|
||||||
|
const blob = new Blob([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.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
|
||||||
|
mermaid.initialize({{ startOnLoad: false }});
|
||||||
|
renderMermaidDiagrams();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
title, content
|
title, content
|
||||||
@@ -109,7 +252,7 @@ fn read_file(path: &Path) -> Result<String> {
|
|||||||
.with_context(|| format!("Failed to read file: {}", path.display()))
|
.with_context(|| format!("Failed to read file: {}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_file(file_path: &str) -> Result<()> {
|
fn process_file(file_path: &str, output: Option<String>) -> Result<()> {
|
||||||
let path = Path::new(file_path);
|
let path = Path::new(file_path);
|
||||||
let content = read_file(path)?;
|
let content = read_file(path)?;
|
||||||
|
|
||||||
@@ -118,7 +261,59 @@ fn process_file(file_path: &str) -> Result<()> {
|
|||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("Markdown");
|
.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<String>) -> 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,12 +439,42 @@ fn list_directory(path: &Path) -> String {
|
|||||||
files.join("\n")
|
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() {
|
fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Render { file } => {
|
Commands::Render { file, output } => {
|
||||||
if let Err(e) = process_file(&file) {
|
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);
|
eprintln!("Error: {}", e);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -260,5 +485,23 @@ fn main() {
|
|||||||
std::process::exit(1);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user