mirror of
https://github.com/imxv/Pretty-mermaid-skills.git
synced 2026-09-14 20:26:57 +08:00
feat: add native PNG output (#9)
Add native PNG rendering with validated sizing, CSS-variable preparation for beautiful-mermaid output, batch support, documentation, and cross-version regression coverage. Includes fixes from all CodeRabbit review rounds.
This commit is contained in:
@@ -27,8 +27,9 @@ body:
|
||||
label: Output format
|
||||
options:
|
||||
- SVG
|
||||
- PNG
|
||||
- Unicode or ASCII
|
||||
- Both
|
||||
- Multiple formats
|
||||
- Documentation only
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -6,6 +6,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Native Node.js PNG output for single-file and batch rendering, with configurable width and no external converter requirement.
|
||||
|
||||
## [1.0.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
**Beautiful Mermaid diagrams for AI agents**
|
||||
|
||||
Turn Mermaid source into polished SVGs and terminal-ready ASCII—locally, without a browser.
|
||||
Turn Mermaid source into polished SVGs, shareable PNGs, and terminal-ready ASCII—locally, without a browser.
|
||||
|
||||

|
||||

|
||||
|
||||
[](https://www.skills.sh/imxv/pretty-mermaid-skills/pretty-mermaid)
|
||||
[](https://github.com/imxv/Pretty-mermaid-skills/actions/workflows/ci.yml)
|
||||
@@ -29,13 +29,13 @@ npx skills add imxv/pretty-mermaid-skills@pretty-mermaid -g -y
|
||||
## Why Pretty Mermaid?
|
||||
|
||||
- **Made for AI agents**: works with Claude Code, Cursor, Codex, Gemini CLI, and more
|
||||
- **One source, two outputs**: polished SVG for docs and ASCII/Unicode for terminals
|
||||
- **One source, three outputs**: polished SVG for docs, PNG for sharing, and ASCII/Unicode for terminals
|
||||
- **No browser required**: renders locally without Chromium, Puppeteer, or a DOM
|
||||
- **Flexible by default**: 15 themes, custom colors, six diagram types, and batch rendering
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 📊 **Multi-format Support**: SVG and ASCII rendering export
|
||||
- 📊 **Multi-format Support**: SVG, PNG, and ASCII rendering export
|
||||
- 🎨 **Rich Themes**: 15 built-in themes for different scenarios
|
||||
- 📈 **Six Diagram Types**: Flowchart, Sequence, State, Class, ER, and XY charts
|
||||
- ⚡ **High Performance**: Batch parallel rendering
|
||||
@@ -100,6 +100,16 @@ node scripts/render.mjs \
|
||||
--theme tokyo-night
|
||||
```
|
||||
|
||||
### Render PNG
|
||||
```bash
|
||||
node scripts/render.mjs \
|
||||
--input diagram.mmd \
|
||||
--output output.png \
|
||||
--format png \
|
||||
--width 1200 \
|
||||
--theme tokyo-night
|
||||
```
|
||||
|
||||
### Batch Render
|
||||
```bash
|
||||
node scripts/batch.mjs \
|
||||
@@ -118,7 +128,7 @@ Check the 6 template files in `assets/example_diagrams/`:
|
||||
- `er.mmd` - ER Diagram
|
||||
- `xychart.mmd` - XY Chart (bar and line)
|
||||
|
||||
The renderer also supports CJK state names, multiline labels, `linkStyle`, configurable ELK layout spacing, interactive XY chart tooltips, and ANSI-colored terminal output.
|
||||
PNG output is rendered directly in Node.js with no external converter required. The renderer also supports CJK state names, multiline labels, `linkStyle`, configurable ELK layout spacing, interactive XY chart tooltips, and ANSI-colored terminal output.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
|
||||
+15
-5
@@ -4,9 +4,9 @@
|
||||
|
||||
**为 AI Agent 打造的精美 Mermaid 图表**
|
||||
|
||||
将 Mermaid 源码转换为精美 SVG 与终端友好的 ASCII——本地运行,无需浏览器。
|
||||
将 Mermaid 源码转换为精美 SVG、便于分享的 PNG 与终端友好的 ASCII——本地运行,无需浏览器。
|
||||
|
||||

|
||||

|
||||
|
||||
[](https://www.skills.sh/imxv/pretty-mermaid-skills/pretty-mermaid)
|
||||
[](https://github.com/imxv/Pretty-mermaid-skills/actions/workflows/ci.yml)
|
||||
@@ -29,13 +29,13 @@ npx skills add imxv/pretty-mermaid-skills@pretty-mermaid -g -y
|
||||
## 为什么选择 Pretty Mermaid?
|
||||
|
||||
- **专为 AI Agent 设计**:支持 Claude Code、Cursor、Codex、Gemini CLI 等环境
|
||||
- **一份源码,两种输出**:文档用精美 SVG,终端用 ASCII/Unicode
|
||||
- **一份源码,三种输出**:文档用精美 SVG,分享用 PNG,终端用 ASCII/Unicode
|
||||
- **无需浏览器**:本地渲染,不依赖 Chromium、Puppeteer 或 DOM
|
||||
- **灵活开箱即用**:15 种主题、自定义配色、六种图表类型和批量渲染
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 📊 **多格式支持**:支持 SVG 和 ASCII 渲染导出
|
||||
- 📊 **多格式支持**:支持 SVG、PNG 和 ASCII 渲染导出
|
||||
- 🎨 **丰富主题**:内置 15 种精美主题,满足不同场景需求
|
||||
- 📈 **六种图表类型**:支持 Flowchart、Sequence、State、Class、ER 和 XY Chart
|
||||
- ⚡ **高效渲染**:支持批量并行渲染,速度飞快
|
||||
@@ -100,6 +100,16 @@ node scripts/render.mjs \
|
||||
--theme tokyo-night
|
||||
```
|
||||
|
||||
### 渲染 PNG
|
||||
```bash
|
||||
node scripts/render.mjs \
|
||||
--input diagram.mmd \
|
||||
--output output.png \
|
||||
--format png \
|
||||
--width 1200 \
|
||||
--theme tokyo-night
|
||||
```
|
||||
|
||||
### 批量渲染
|
||||
```bash
|
||||
node scripts/batch.mjs \
|
||||
@@ -118,7 +128,7 @@ node scripts/batch.mjs \
|
||||
- `er.mmd` - ER 图
|
||||
- `xychart.mmd` - XY 图(柱状图与折线图)
|
||||
|
||||
渲染器同时支持中日韩状态名称、多行标签、`linkStyle`、可配置的 ELK 布局间距、XY 图交互提示,以及带 ANSI 颜色的终端输出。
|
||||
PNG 由 Node.js 直接生成,无需安装外部转换工具。渲染器同时支持中日韩状态名称、多行标签、`linkStyle`、可配置的 ELK 布局间距、XY 图交互提示,以及带 ANSI 颜色的终端输出。
|
||||
|
||||
## 📚 完整文档
|
||||
|
||||
|
||||
+15
-5
@@ -4,9 +4,9 @@
|
||||
|
||||
**AI エージェントのための美しい Mermaid ダイアグラム**
|
||||
|
||||
Mermaid ソースを洗練された SVG とターミナル向け ASCII に変換します。ローカルで動作し、ブラウザーは不要です。
|
||||
Mermaid ソースを洗練された SVG、共有しやすい PNG、ターミナル向け ASCII に変換します。ローカルで動作し、ブラウザーは不要です。
|
||||
|
||||

|
||||

|
||||
|
||||
[](https://www.skills.sh/imxv/pretty-mermaid-skills/pretty-mermaid)
|
||||
[](https://github.com/imxv/Pretty-mermaid-skills/actions/workflows/ci.yml)
|
||||
@@ -29,13 +29,13 @@ npx skills add imxv/pretty-mermaid-skills@pretty-mermaid -g -y
|
||||
## Pretty Mermaid を選ぶ理由
|
||||
|
||||
- **AI エージェント向け**:Claude Code、Cursor、Codex、Gemini CLI などに対応
|
||||
- **1 つのソースから 2 形式**:ドキュメント向け SVG とターミナル向け ASCII/Unicode
|
||||
- **1 つのソースから 3 形式**:ドキュメント向け SVG、共有向け PNG、ターミナル向け ASCII/Unicode
|
||||
- **ブラウザー不要**:Chromium、Puppeteer、DOM に依存せずローカルでレンダリング
|
||||
- **柔軟な設定**:15 テーマ、カスタムカラー、6 種類のダイアグラム、バッチ処理
|
||||
|
||||
## ✨ 主な機能
|
||||
|
||||
- 📊 **複数形式**:SVG と ASCII/Unicode を出力
|
||||
- 📊 **複数形式**:SVG、PNG、ASCII/Unicode を出力
|
||||
- 🎨 **豊富なテーマ**:用途に合わせた 15 の組み込みテーマ
|
||||
- 📈 **6 種類のダイアグラム**:Flowchart、Sequence、State、Class、ER、XY Chart
|
||||
- ⚡ **高速処理**:複数ファイルを並列でバッチレンダリング
|
||||
@@ -98,6 +98,16 @@ node scripts/render.mjs \
|
||||
--theme tokyo-night
|
||||
```
|
||||
|
||||
### PNG をレンダリング
|
||||
```bash
|
||||
node scripts/render.mjs \
|
||||
--input diagram.mmd \
|
||||
--output output.png \
|
||||
--format png \
|
||||
--width 1200 \
|
||||
--theme tokyo-night
|
||||
```
|
||||
|
||||
### ディレクトリをバッチレンダリング
|
||||
|
||||
```bash
|
||||
@@ -118,7 +128,7 @@ node scripts/batch.mjs \
|
||||
- `er.mmd` - ER 図
|
||||
- `xychart.mmd` - XY チャート(棒グラフと折れ線グラフ)
|
||||
|
||||
CJK の状態名、複数行ラベル、`linkStyle`、ELK レイアウト間隔、XY チャートのツールチップ、ANSI カラーのターミナル出力にも対応します。
|
||||
PNG は外部コンバーターを使わず Node.js 内で直接生成します。CJK の状態名、複数行ラベル、`linkStyle`、ELK レイアウト間隔、XY チャートのツールチップ、ANSI カラーのターミナル出力にも対応します。
|
||||
|
||||
## 📚 ドキュメント
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: pretty-mermaid
|
||||
description: |
|
||||
Generate and render Mermaid diagrams for architecture docs, READMEs, PRs,
|
||||
terminals, and CI as themed SVG or ASCII/Unicode art. Use this skill whenever
|
||||
terminals, chats, and CI as themed SVG, PNG, or ASCII/Unicode art. Use this skill whenever
|
||||
the user provides Mermaid code or .mmd files; asks for a flowchart,
|
||||
sequence/state/class diagram, ERD, XY chart, or architecture/workflow/data-model
|
||||
visualization; or wants to beautify, theme, batch-convert, or make a diagram
|
||||
@@ -12,7 +12,7 @@ description: |
|
||||
|
||||
# Pretty Mermaid
|
||||
|
||||
Create or render Mermaid diagrams with the bundled Node.js CLI. Use SVG for documentation and presentations; use ASCII or Unicode for terminals and plain text.
|
||||
Create or render Mermaid diagrams with the bundled Node.js CLI. Use SVG for scalable documentation, PNG for sharing or raster-only consumers, and ASCII or Unicode for terminals and plain text.
|
||||
|
||||
## Working directory
|
||||
|
||||
@@ -48,6 +48,7 @@ Read `references/DIAGRAM_TYPES.md` when authoring non-trivial Mermaid syntax.
|
||||
| Output | Best for | Notes |
|
||||
| --- | --- | --- |
|
||||
| SVG | READMEs, docs, slides, websites | Scalable, themed, supports transparency |
|
||||
| PNG | Chats, previews, raster-only tools | Set `--format png`; no external converter required |
|
||||
| Unicode | Modern terminals and readable text previews | Default ASCII renderer output |
|
||||
| Plain ASCII | Logs and restricted terminals | Add `--use-ascii` |
|
||||
| ANSI-colored text | Interactive terminals | Set `--color-mode` |
|
||||
@@ -83,6 +84,17 @@ node scripts/render.mjs \
|
||||
|
||||
Add `--use-ascii` when Unicode box-drawing characters are not acceptable.
|
||||
|
||||
### Render PNG
|
||||
|
||||
```bash
|
||||
node scripts/render.mjs \
|
||||
--input diagram.mmd \
|
||||
--output diagram.png \
|
||||
--format png \
|
||||
--width 1200 \
|
||||
--theme tokyo-night
|
||||
```
|
||||
|
||||
### Batch render a directory
|
||||
|
||||
```bash
|
||||
@@ -129,6 +141,13 @@ Read `references/THEMES.md` or open `docs/THEME_GALLERY.md` when visual theme ch
|
||||
| `--component-spacing <n>` | Separate disconnected components |
|
||||
| `--interactive` | Enable XY chart hover tooltips |
|
||||
|
||||
### PNG
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `--width <n>` | Set output width from 100 to 10000 pixels while preserving aspect ratio |
|
||||
| `--transparent` | Preserve a transparent background |
|
||||
|
||||
### Terminal output
|
||||
|
||||
| Option | Purpose |
|
||||
@@ -155,7 +174,7 @@ Run `node scripts/render.mjs --help` or `node scripts/batch.mjs --help` for the
|
||||
After rendering:
|
||||
|
||||
1. Confirm the command exits successfully and the output file is non-empty.
|
||||
2. Confirm SVG output begins with `<svg`; confirm text output contains visible diagram content.
|
||||
2. Confirm SVG output begins with `<svg`; confirm PNG output opens as a valid image; confirm text output contains visible diagram content.
|
||||
3. Inspect visual output when layout matters, especially long labels, CJK text, disconnected components, and XY charts.
|
||||
4. Confirm arrows, cardinalities, states, and labels match the source request.
|
||||
5. Report any renderer limitation instead of silently dropping unsupported syntax.
|
||||
@@ -168,6 +187,7 @@ Run both `npm test` and `npm run validate` when changing this skill, its scripts
|
||||
- Unknown theme: run `node scripts/themes.mjs` and use an exact listed name.
|
||||
- Parse error: consult `references/DIAGRAM_TYPES.md`, reduce to the failing statement, then restore the diagram incrementally.
|
||||
- Crowded SVG: increase `--node-spacing`, `--layer-spacing`, or `--component-spacing`.
|
||||
- PNG color error: use concrete hex values for custom colors; unresolved external CSS variables cannot be rasterized.
|
||||
- Terminal color escape codes in redirected output: use `--color-mode none`.
|
||||
|
||||
## Reference routing
|
||||
|
||||
Generated
+216
@@ -8,6 +8,7 @@
|
||||
"name": "pretty-mermaid-skill",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"beautiful-mermaid": "^1.1.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -19,6 +20,221 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
"integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@resvg/resvg-js-android-arm-eabi": "2.6.2",
|
||||
"@resvg/resvg-js-android-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-arm64": "2.6.2",
|
||||
"@resvg/resvg-js-darwin-x64": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-arm64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-gnu": "2.6.2",
|
||||
"@resvg/resvg-js-linux-x64-musl": "2.6.2",
|
||||
"@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
|
||||
"@resvg/resvg-js-win32-x64-msvc": "2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm-eabi": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
|
||||
"integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-android-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-arm64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
|
||||
"integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-darwin-x64": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
|
||||
"integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
|
||||
"integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-arm64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-gnu": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
|
||||
"integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-linux-x64-musl": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
|
||||
"integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-arm64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-ia32-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@resvg/resvg-js-win32-x64-msvc": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
|
||||
"integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/beautiful-mermaid": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/beautiful-mermaid/-/beautiful-mermaid-1.1.3.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"validate": "node scripts/validate-docs.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"beautiful-mermaid": "^1.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -4,6 +4,7 @@ import { execSync } from 'child_process';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { DEFAULT_PNG_WIDTH, parsePngWidth, renderSvgToPng } from './png.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = join(__dirname, '..');
|
||||
@@ -84,6 +85,7 @@ function parseArgs() {
|
||||
componentSpacing: 24,
|
||||
interactive: false,
|
||||
workers: 4,
|
||||
width: DEFAULT_PNG_WIDTH,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -115,13 +117,16 @@ function parseArgs() {
|
||||
case '--component-spacing': opts.componentSpacing = parseInt(val); i++; break;
|
||||
case '--interactive': opts.interactive = true; break;
|
||||
case '--workers': case '-w': opts.workers = parseInt(val); i++; break;
|
||||
case '--width':
|
||||
if (val === undefined) throw new Error('--width requires a value.');
|
||||
opts.width = val; i++; break;
|
||||
case '--help': case '-h':
|
||||
console.log(`Usage: node batch.mjs --input-dir <dir> --output-dir <dir> [options]
|
||||
|
||||
Options:
|
||||
-i, --input-dir <dir> Input directory containing .mmd files [required]
|
||||
-o, --output-dir <dir> Output directory for rendered files [required]
|
||||
-f, --format <fmt> Output format: svg | ascii (default: svg)
|
||||
-f, --format <fmt> Output format: svg | png | ascii (default: svg)
|
||||
-t, --theme <name> Theme name (e.g. tokyo-night, dracula)
|
||||
--bg <hex> Background color
|
||||
--fg <hex> Foreground color
|
||||
@@ -131,7 +136,8 @@ Options:
|
||||
--surface <hex> Node fill tint color
|
||||
--border <hex> Node stroke color
|
||||
--font <name> Font family (default: Inter)
|
||||
--transparent Transparent background (SVG only)
|
||||
--transparent Transparent background (SVG and PNG)
|
||||
--width <n> PNG width in pixels (100-10000, default: 800)
|
||||
--use-ascii Pure ASCII instead of Unicode (ASCII only)
|
||||
--padding-x <n> Horizontal spacing (ASCII only, default: 5)
|
||||
--padding-y <n> Vertical spacing (ASCII only, default: 5)
|
||||
@@ -160,13 +166,22 @@ Options:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!['svg', 'png', 'ascii'].includes(opts.format)) {
|
||||
console.error(`Error: Unsupported format: ${opts.format}. Use svg, png, or ascii.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.format === 'png') {
|
||||
opts.width = parsePngWidth(opts.width);
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function renderFile(file, inputDir, outputDir, opts, lib) {
|
||||
const { renderMermaidSVG, renderMermaidASCII, THEMES } = lib;
|
||||
const inputPath = join(inputDir, file);
|
||||
const ext = opts.format === 'svg' ? '.svg' : '.txt';
|
||||
const ext = opts.format === 'svg' ? '.svg' : opts.format === 'png' ? '.png' : '.txt';
|
||||
const outputPath = join(outputDir, file.replace(/\.mmd$/, ext));
|
||||
const input = readFileSync(inputPath, 'utf8');
|
||||
const theme = opts.theme ? THEMES[opts.theme] : undefined;
|
||||
@@ -210,7 +225,7 @@ async function renderFile(file, inputDir, outputDir, opts, lib) {
|
||||
componentSpacing: opts.componentSpacing,
|
||||
interactive: opts.interactive,
|
||||
});
|
||||
writeFileSync(outputPath, svg);
|
||||
writeFileSync(outputPath, opts.format === 'png' ? renderSvgToPng(svg, opts.width) : svg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
import { Resvg } from '@resvg/resvg-js';
|
||||
|
||||
export const DEFAULT_PNG_WIDTH = 800;
|
||||
export const MIN_PNG_WIDTH = 100;
|
||||
export const MAX_PNG_WIDTH = 10000;
|
||||
|
||||
const CUSTOM_PROPERTY = /(--[\w-]+)\s*:\s*([^;}]+);?/g;
|
||||
const CSS_RULE = /([^{}]+)\{([^{}]*)\}/g;
|
||||
const HEX_COLOR = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i;
|
||||
|
||||
export function parsePngWidth(value = DEFAULT_PNG_WIDTH) {
|
||||
const text = String(value);
|
||||
if (!/^\d+$/.test(text)) {
|
||||
throw new Error(`PNG width must be an integer from ${MIN_PNG_WIDTH} to ${MAX_PNG_WIDTH}.`);
|
||||
}
|
||||
|
||||
const width = Number(text);
|
||||
if (width < MIN_PNG_WIDTH || width > MAX_PNG_WIDTH) {
|
||||
throw new Error(`PNG width must be an integer from ${MIN_PNG_WIDTH} to ${MAX_PNG_WIDTH}.`);
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
export function prepareSvgForPng(svg) {
|
||||
const rootTag = svg.match(/<svg\b[^>]*>/i)?.[0];
|
||||
if (!rootTag) {
|
||||
throw new Error('PNG conversion requires a valid SVG document.');
|
||||
}
|
||||
|
||||
const stylesheets = [...svg.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)]
|
||||
.map(match => stripCssImports(match[1]));
|
||||
const rootDeclarations = new Map();
|
||||
let declarationOrder = 0;
|
||||
for (const css of stylesheets) {
|
||||
declarationOrder = collectRootCustomProperties(css, rootDeclarations, declarationOrder);
|
||||
}
|
||||
const rootVariables = new Map(
|
||||
[...rootDeclarations].map(([name, declaration]) => [name, declaration.value]),
|
||||
);
|
||||
const rootStyle = rootTag.match(/\sstyle=(['"])(.*?)\1/i)?.[2] ?? '';
|
||||
collectCustomProperties(rootStyle, rootVariables);
|
||||
rejectScopedInlineCustomProperties(svg);
|
||||
const resolveRootVariable = createVariableResolver(rootVariables);
|
||||
|
||||
const prepared = mapCssContexts(
|
||||
svg,
|
||||
css => resolveCssValue(stripCssImports(css), resolveRootVariable).replace(CUSTOM_PROPERTY, ''),
|
||||
(_, value) => resolveCssValue(value, resolveRootVariable).replace(CUSTOM_PROPERTY, ''),
|
||||
);
|
||||
|
||||
let unresolved;
|
||||
forEachCssContext(prepared, context => {
|
||||
unresolved ||= context.match(/(?:^|[^-\w])((?:var|color-mix)\s*\()/i)?.[1];
|
||||
});
|
||||
if (unresolved) {
|
||||
throw new Error(`PNG conversion cannot resolve CSS expression ${unresolved}`);
|
||||
}
|
||||
|
||||
const backgroundValue = rootStyle.match(/(?:^|;)\s*background(?:-color)?\s*:\s*([^;]+)/i)?.[1];
|
||||
const background = backgroundValue
|
||||
? resolveCssValue(backgroundValue, resolveRootVariable)
|
||||
: undefined;
|
||||
|
||||
return { svg: prepared, background };
|
||||
}
|
||||
|
||||
export function renderSvgForPng(svg, width = DEFAULT_PNG_WIDTH) {
|
||||
const validWidth = parsePngWidth(width);
|
||||
const prepared = prepareSvgForPng(svg);
|
||||
const renderer = new Resvg(prepared.svg, {
|
||||
fitTo: { mode: 'width', value: validWidth },
|
||||
...(prepared.background && { background: prepared.background }),
|
||||
font: { loadSystemFonts: true },
|
||||
});
|
||||
|
||||
return renderer.render();
|
||||
}
|
||||
|
||||
export function renderSvgToPng(svg, width = DEFAULT_PNG_WIDTH) {
|
||||
return Buffer.from(renderSvgForPng(svg, width).asPng());
|
||||
}
|
||||
|
||||
function collectCustomProperties(source, variables) {
|
||||
for (const match of source.matchAll(CUSTOM_PROPERTY)) {
|
||||
variables.set(match[1], match[2].trim());
|
||||
}
|
||||
}
|
||||
|
||||
function collectRootCustomProperties(css, declarations, startOrder) {
|
||||
let order = startOrder;
|
||||
for (const match of css.matchAll(CSS_RULE)) {
|
||||
const selectors = match[1].split(',').map(selector => selector.trim());
|
||||
const customProperties = [...match[2].matchAll(CUSTOM_PROPERTY)];
|
||||
if (customProperties.length === 0) continue;
|
||||
|
||||
const specificity = Math.max(...selectors.map(rootSelectorSpecificity));
|
||||
if (specificity < 0) {
|
||||
throw new Error('PNG conversion supports CSS custom properties only on the root svg element.');
|
||||
}
|
||||
|
||||
for (const property of customProperties) {
|
||||
const candidate = { value: property[2].trim(), specificity, order: order++ };
|
||||
const current = declarations.get(property[1]);
|
||||
if (
|
||||
!current ||
|
||||
candidate.specificity > current.specificity ||
|
||||
(candidate.specificity === current.specificity && candidate.order > current.order)
|
||||
) {
|
||||
declarations.set(property[1], candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function rootSelectorSpecificity(selector) {
|
||||
if (selector === ':root') return 10;
|
||||
if (selector === 'svg') return 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function rejectScopedInlineCustomProperties(svg) {
|
||||
for (const match of svg.matchAll(/<([a-z][\w:-]*)\b[^>]*\sstyle=(['"])(.*?)\2[^>]*>/gi)) {
|
||||
if (match[1].toLowerCase() !== 'svg' && [...match[3].matchAll(CUSTOM_PROPERTY)].length > 0) {
|
||||
throw new Error('PNG conversion supports CSS custom properties only on the root svg element.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createVariableResolver(variables) {
|
||||
const resolved = new Map();
|
||||
|
||||
return function resolveVariable(name, stack = []) {
|
||||
if (resolved.has(name)) return resolved.get(name);
|
||||
if (stack.includes(name)) {
|
||||
throw new Error(`Circular CSS variable reference: ${[...stack, name].join(' -> ')}`);
|
||||
}
|
||||
if (!variables.has(name)) {
|
||||
throw new Error(`PNG conversion cannot resolve CSS variable ${name}. Use concrete color values.`);
|
||||
}
|
||||
|
||||
const value = resolveCssValue(variables.get(name), resolveVariable, [...stack, name]);
|
||||
resolved.set(name, value);
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
function stripCssImports(css) {
|
||||
return css.replace(/@import\s+url\([^;]*\);?/gi, '');
|
||||
}
|
||||
|
||||
function forEachCssContext(svg, visit) {
|
||||
for (const match of svg.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
||||
visit(match[1]);
|
||||
}
|
||||
for (const match of svg.matchAll(/\s(?:style|fill|stroke|filter)=(['"])(.*?)\1/gi)) {
|
||||
visit(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
function mapCssContexts(svg, transformStylesheet, transformAttribute) {
|
||||
return svg
|
||||
.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_, open, css, close) => (
|
||||
`${open}${transformStylesheet(css)}${close}`
|
||||
))
|
||||
.replace(/(\s)(style|fill|stroke|filter)=(['"])(.*?)\3/gi, (_, space, name, quote, value) => (
|
||||
`${space}${name}=${quote}${transformAttribute(name, value)}${quote}`
|
||||
));
|
||||
}
|
||||
|
||||
function resolveCssValue(value, resolveVariable, stack = []) {
|
||||
let resolved = replaceCssFunctions(value, 'var', inner => {
|
||||
const [name, fallback] = splitTopLevel(inner, ',');
|
||||
const variableName = name.trim();
|
||||
|
||||
try {
|
||||
return resolveVariable(variableName, stack);
|
||||
} catch (error) {
|
||||
if (fallback === undefined || !error.message.startsWith('PNG conversion cannot resolve CSS variable')) {
|
||||
throw error;
|
||||
}
|
||||
return resolveCssValue(fallback.trim(), resolveVariable, stack);
|
||||
}
|
||||
});
|
||||
|
||||
resolved = replaceCssFunctions(resolved, 'color-mix', mixCssColors);
|
||||
return resolved.trim();
|
||||
}
|
||||
|
||||
function replaceCssFunctions(source, functionName, replace) {
|
||||
const prefix = `${functionName.toLowerCase()}(`;
|
||||
const lowerSource = source.toLowerCase();
|
||||
let cursor = 0;
|
||||
let output = '';
|
||||
|
||||
while (cursor < source.length) {
|
||||
let start = lowerSource.indexOf(prefix, cursor);
|
||||
while (start !== -1 && start > 0 && /[-_a-z0-9]/i.test(source[start - 1])) {
|
||||
start = lowerSource.indexOf(prefix, start + prefix.length);
|
||||
}
|
||||
if (start === -1) {
|
||||
output += source.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
output += source.slice(cursor, start);
|
||||
let depth = 1;
|
||||
let end = start + prefix.length;
|
||||
while (end < source.length && depth > 0) {
|
||||
if (source[end] === '(') depth++;
|
||||
if (source[end] === ')') depth--;
|
||||
end++;
|
||||
}
|
||||
|
||||
if (depth !== 0) {
|
||||
throw new Error(`Unclosed CSS function ${functionName}().`);
|
||||
}
|
||||
|
||||
const inner = source.slice(start + prefix.length, end - 1);
|
||||
output += replace(inner);
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function mixCssColors(expression) {
|
||||
const parts = splitTopLevel(expression, ',').map(part => part.trim());
|
||||
if (parts.length !== 3 || parts[0].toLowerCase() !== 'in srgb') {
|
||||
throw new Error(`Unsupported CSS color mix: color-mix(${expression})`);
|
||||
}
|
||||
|
||||
const first = parseWeightedColor(parts[1]);
|
||||
const second = parseWeightedColor(parts[2]);
|
||||
if (first.weight === undefined && second.weight === undefined) {
|
||||
first.weight = 50;
|
||||
second.weight = 50;
|
||||
} else if (first.weight === undefined) {
|
||||
first.weight = 100 - second.weight;
|
||||
} else if (second.weight === undefined) {
|
||||
second.weight = 100 - first.weight;
|
||||
}
|
||||
|
||||
if (first.weight < 0 || second.weight < 0) {
|
||||
throw new Error(`Invalid CSS color mix: color-mix(${expression})`);
|
||||
}
|
||||
|
||||
const total = first.weight + second.weight;
|
||||
if (total <= 0) {
|
||||
throw new Error(`Invalid CSS color mix: color-mix(${expression})`);
|
||||
}
|
||||
|
||||
const firstWeight = first.weight / total;
|
||||
const secondWeight = second.weight / total;
|
||||
const mixedAlpha = first.color.a * firstWeight + second.color.a * secondWeight;
|
||||
if (mixedAlpha === 0) return 'transparent';
|
||||
|
||||
const channel = key => Math.round(
|
||||
(first.color[key] * first.color.a * firstWeight + second.color[key] * second.color.a * secondWeight) / mixedAlpha,
|
||||
);
|
||||
const alpha = mixedAlpha * Math.min(1, total / 100);
|
||||
const color = { r: channel('r'), g: channel('g'), b: channel('b'), a: alpha };
|
||||
return formatColor(color);
|
||||
}
|
||||
|
||||
function parseWeightedColor(value) {
|
||||
const match = value.match(/^(.*?)\s+([\d.]+)%$/);
|
||||
const colorText = match ? match[1].trim() : value.trim();
|
||||
const weight = match ? Number(match[2]) : undefined;
|
||||
if (weight !== undefined && (!Number.isFinite(weight) || weight < 0 || weight > 100)) {
|
||||
throw new Error(`Invalid CSS color weight: ${value}`);
|
||||
}
|
||||
|
||||
return { color: parseColor(colorText), weight };
|
||||
}
|
||||
|
||||
function parseColor(value) {
|
||||
if (value.toLowerCase() === 'transparent') {
|
||||
return { r: 0, g: 0, b: 0, a: 0 };
|
||||
}
|
||||
|
||||
const match = value.match(HEX_COLOR);
|
||||
if (!match) {
|
||||
throw new Error(`PNG conversion supports hex colors, but received: ${value}`);
|
||||
}
|
||||
|
||||
let hex = match[1];
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
hex = [...hex].map(character => character.repeat(2)).join('');
|
||||
}
|
||||
if (hex.length === 6) hex += 'ff';
|
||||
|
||||
return {
|
||||
r: Number.parseInt(hex.slice(0, 2), 16),
|
||||
g: Number.parseInt(hex.slice(2, 4), 16),
|
||||
b: Number.parseInt(hex.slice(4, 6), 16),
|
||||
a: Number.parseInt(hex.slice(6, 8), 16) / 255,
|
||||
};
|
||||
}
|
||||
|
||||
function formatColor({ r, g, b, a }) {
|
||||
if (a >= 1) {
|
||||
return `#${[r, g, b].map(channel => channel.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(4))})`;
|
||||
}
|
||||
|
||||
function splitTopLevel(source, delimiter) {
|
||||
const parts = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
|
||||
for (let index = 0; index < source.length; index++) {
|
||||
if (source[index] === '(') depth++;
|
||||
if (source[index] === ')') depth--;
|
||||
if (source[index] === delimiter && depth === 0) {
|
||||
parts.push(source.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(source.slice(start));
|
||||
return parts;
|
||||
}
|
||||
+25
-4
@@ -4,6 +4,7 @@ import { execSync } from 'child_process';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { DEFAULT_PNG_WIDTH, parsePngWidth, renderSvgToPng } from './png.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = join(__dirname, '..');
|
||||
@@ -78,6 +79,7 @@ function parseArgs() {
|
||||
layerSpacing: 40,
|
||||
componentSpacing: 24,
|
||||
interactive: false,
|
||||
width: DEFAULT_PNG_WIDTH,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -108,13 +110,16 @@ function parseArgs() {
|
||||
case '--layer-spacing': opts.layerSpacing = parseInt(val); i++; break;
|
||||
case '--component-spacing': opts.componentSpacing = parseInt(val); i++; break;
|
||||
case '--interactive': opts.interactive = true; break;
|
||||
case '--width':
|
||||
if (val === undefined) throw new Error('--width requires a value.');
|
||||
opts.width = val; i++; break;
|
||||
case '--help': case '-h':
|
||||
console.log(`Usage: node render.mjs --input <file> [options]
|
||||
|
||||
Options:
|
||||
-i, --input <file> Input Mermaid file (.mmd) [required]
|
||||
-o, --output <file> Output file (default: stdout)
|
||||
-f, --format <fmt> Output format: svg | ascii (default: svg)
|
||||
-o, --output <file> Output file (default: stdout; input.png for PNG)
|
||||
-f, --format <fmt> Output format: svg | png | ascii (default: svg)
|
||||
-t, --theme <name> Theme name (e.g. tokyo-night, dracula)
|
||||
--bg <hex> Background color
|
||||
--fg <hex> Foreground color
|
||||
@@ -124,7 +129,8 @@ Options:
|
||||
--surface <hex> Node fill tint color
|
||||
--border <hex> Node stroke color
|
||||
--font <name> Font family (default: Inter)
|
||||
--transparent Transparent background (SVG only)
|
||||
--transparent Transparent background (SVG and PNG)
|
||||
--width <n> PNG width in pixels (100-10000, default: 800)
|
||||
--use-ascii Pure ASCII instead of Unicode (ASCII only)
|
||||
--padding-x <n> Horizontal spacing (ASCII only, default: 5)
|
||||
--padding-y <n> Vertical spacing (ASCII only, default: 5)
|
||||
@@ -149,6 +155,15 @@ Options:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!['svg', 'png', 'ascii'].includes(opts.format)) {
|
||||
console.error(`Error: Unsupported format: ${opts.format}. Use svg, png, or ascii.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (opts.format === 'png') {
|
||||
opts.width = parsePngWidth(opts.width);
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
@@ -207,7 +222,13 @@ async function main() {
|
||||
interactive: opts.interactive,
|
||||
});
|
||||
|
||||
if (opts.output) {
|
||||
if (opts.format === 'png') {
|
||||
const outputPath = opts.output || (
|
||||
/\.mmd$/i.test(opts.input) ? opts.input.replace(/\.mmd$/i, '.png') : `${opts.input}.png`
|
||||
);
|
||||
writeFileSync(outputPath, renderSvgToPng(svg, opts.width));
|
||||
console.log(`PNG diagram saved to ${outputPath}`);
|
||||
} else if (opts.output) {
|
||||
writeFileSync(opts.output, svg);
|
||||
console.log(`SVG diagram saved to ${opts.output}`);
|
||||
} else {
|
||||
|
||||
+88
-1
@@ -11,11 +11,13 @@ import {
|
||||
renderMermaidSVG,
|
||||
THEMES,
|
||||
} from 'beautiful-mermaid';
|
||||
import { parsePngWidth, prepareSvgForPng, renderSvgForPng, renderSvgToPng } from './png.mjs';
|
||||
|
||||
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
||||
const examplesDir = join(scriptsDir, '..', 'assets', 'example_diagrams');
|
||||
const files = readdirSync(examplesDir).filter(file => file.endsWith('.mmd')).sort();
|
||||
const inheritedThemeNames = ['toString', 'constructor', '__proto__'];
|
||||
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
assert.equal(files.length, 6, 'Expected six example diagrams');
|
||||
assert.equal(Object.keys(THEMES).length, 15, 'Expected 15 built-in themes');
|
||||
@@ -24,9 +26,66 @@ for (const file of files) {
|
||||
const source = readFileSync(join(examplesDir, file), 'utf8');
|
||||
const svg = renderMermaidSVG(source, THEMES['tokyo-night']);
|
||||
const ascii = renderMermaidASCII(source, { colorMode: 'none' });
|
||||
const preparedSvg = prepareSvgForPng(svg).svg;
|
||||
const png = renderSvgToPng(svg, 320);
|
||||
|
||||
assert.ok(svg.startsWith('<svg'), `${file} did not render valid SVG`);
|
||||
assert.ok(ascii.trim().length > 0, `${file} did not render ASCII output`);
|
||||
assert.doesNotMatch(preparedSvg, /(?:var|color-mix)\s*\(/, `${file} retained unsupported CSS`);
|
||||
assert.ok(png.subarray(0, 8).equals(pngSignature), `${file} did not render valid PNG`);
|
||||
assert.equal(png.readUInt32BE(16), 320, `${file} PNG width was not applied`);
|
||||
}
|
||||
|
||||
assert.throws(() => parsePngWidth('800; echo unsafe'), /PNG width must be an integer/);
|
||||
assert.throws(() => parsePngWidth(99), /PNG width must be an integer/);
|
||||
|
||||
const cssLikeLabelSvg = renderMermaidSVG('flowchart LR\n A["var(--user-label)"] --> B["color-mix(in srgb, red, blue)"]');
|
||||
const cssLikeLabelPrepared = prepareSvgForPng(cssLikeLabelSvg).svg;
|
||||
assert.match(cssLikeLabelPrepared, /var\(--user-label\)/);
|
||||
assert.match(cssLikeLabelPrepared, /color-mix\(in srgb, red, blue\)/);
|
||||
assert.ok(renderSvgToPng(cssLikeLabelSvg).subarray(0, 8).equals(pngSignature));
|
||||
|
||||
const literalBackgroundSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" style="background:#fff"></svg>';
|
||||
assert.equal(prepareSvgForPng(literalBackgroundSvg).background, '#fff');
|
||||
assertRenderedPixel(literalBackgroundSvg, [255, 255, 255, 255]);
|
||||
|
||||
const stylesheetOverrideSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" style="background:var(--bg)"><style>svg { --bg:#fff; } svg { --bg:#000; }</style></svg>';
|
||||
assert.equal(prepareSvgForPng(stylesheetOverrideSvg).background, '#000');
|
||||
assertRenderedPixel(stylesheetOverrideSvg, [0, 0, 0, 255]);
|
||||
|
||||
const inlineOverrideSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" style="--bg:#123;background:var(--bg)"><style>svg { --bg:#fff; }</style></svg>';
|
||||
assert.equal(prepareSvgForPng(inlineOverrideSvg).background, '#123');
|
||||
assertRenderedPixel(inlineOverrideSvg, [17, 34, 51, 255]);
|
||||
|
||||
const rootSpecificitySvg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" style="background:var(--bg)"><style>:root { --bg:#fff; } svg { --bg:#000; }</style></svg>';
|
||||
assert.equal(prepareSvgForPng(rootSpecificitySvg).background, '#fff');
|
||||
|
||||
const scopedVariablesSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><style>.theme { --accent:#f00; } .node { fill:var(--accent); }</style><rect class="theme node"/></svg>';
|
||||
assert.throws(
|
||||
() => prepareSvgForPng(scopedVariablesSvg),
|
||||
/custom properties only on the root svg element/,
|
||||
);
|
||||
|
||||
const mixedCaseSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" style="--bg:#fff;background:VAR(--bg)"><style>rect { fill:COLOR-MIX(in srgb, #fff 50%, #000); }</style><rect width="5" height="5" fill="myvar(--bg)" filter="--var(--bg)"/></svg>';
|
||||
const mixedCasePrepared = prepareSvgForPng(mixedCaseSvg);
|
||||
assert.equal(mixedCasePrepared.background, '#fff');
|
||||
assert.match(mixedCasePrepared.svg, /fill:#808080/);
|
||||
assert.match(mixedCasePrepared.svg, /fill="myvar\(--bg\)"/);
|
||||
assert.match(mixedCasePrepared.svg, /filter="--var\(--bg\)"/);
|
||||
|
||||
const partialMixSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" style="background:color-mix(in srgb, #f00 20%, #00f 20%)"></svg>';
|
||||
assert.equal(prepareSvgForPng(partialMixSvg).background, 'rgba(128, 0, 128, 0.4)');
|
||||
// Resvg exposes premultiplied RGBA pixels, so 128 at 40% alpha is stored as 51.
|
||||
assertRenderedPixel(partialMixSvg, [51, 0, 51, 102]);
|
||||
assert.throws(
|
||||
() => prepareSvgForPng('<svg xmlns="http://www.w3.org/2000/svg" style="background:color-mix(in srgb, #f00 120%, #00f)"></svg>'),
|
||||
/Invalid CSS color weight/,
|
||||
);
|
||||
|
||||
function assertRenderedPixel(svg, expectedRgba) {
|
||||
const rendered = renderSvgForPng(svg, 100);
|
||||
assert.ok(rendered.asPng().subarray(0, 8).equals(pngSignature));
|
||||
assert.deepEqual([...rendered.pixels.subarray(0, 4)], expectedRgba);
|
||||
}
|
||||
|
||||
const cliTestDir = mkdtempSync(join(tmpdir(), 'pretty-mermaid-smoke-'));
|
||||
@@ -87,6 +146,34 @@ try {
|
||||
assert.match(batchInteractiveResult.stdout, /xychart\.mmd/);
|
||||
assert.match(readFileSync(join(batchInteractiveDir, 'xychart.svg'), 'utf8'), /class="xychart-tip/);
|
||||
|
||||
const pngPath = join(cliTestDir, 'flowchart.png');
|
||||
const renderPngResult = spawnSync(process.execPath, [
|
||||
join(scriptsDir, 'render.mjs'),
|
||||
'--input', flowchartPath,
|
||||
'--output', pngPath,
|
||||
'--format', 'png',
|
||||
'--theme', 'tokyo-night',
|
||||
'--width', '640',
|
||||
], { encoding: 'utf8' });
|
||||
assert.equal(renderPngResult.status, 0, renderPngResult.stderr);
|
||||
const png = readFileSync(pngPath);
|
||||
assert.ok(png.subarray(0, 8).equals(pngSignature));
|
||||
assert.equal(png.readUInt32BE(16), 640);
|
||||
|
||||
const batchPngDir = join(cliTestDir, 'batch-png');
|
||||
const batchPngResult = spawnSync(process.execPath, [
|
||||
join(scriptsDir, 'batch.mjs'),
|
||||
'--input-dir', examplesDir,
|
||||
'--output-dir', batchPngDir,
|
||||
'--format', 'png',
|
||||
'--theme', 'github-light',
|
||||
'--width', '480',
|
||||
], { encoding: 'utf8' });
|
||||
assert.equal(batchPngResult.status, 0, batchPngResult.stderr);
|
||||
const batchPngFiles = readdirSync(batchPngDir).filter(file => file.endsWith('.png'));
|
||||
assert.equal(batchPngFiles.length, files.length);
|
||||
assert.equal(readFileSync(join(batchPngDir, 'xychart.png')).readUInt32BE(16), 480);
|
||||
|
||||
const themedAsciiPath = join(cliTestDir, 'dracula.txt');
|
||||
const renderThemedAsciiResult = spawnSync(process.execPath, [
|
||||
join(scriptsDir, 'render.mjs'),
|
||||
@@ -147,4 +234,4 @@ try {
|
||||
rmSync(cliTestDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`Smoke tests passed: ${files.length} diagrams x 2 formats, 15 themes, CLI named/custom colors and interactive coverage.`);
|
||||
console.log(`Smoke tests passed: ${files.length} diagrams x 3 formats, 15 themes, CLI named/custom colors and interactive coverage.`);
|
||||
|
||||
Reference in New Issue
Block a user