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:
imxv
2026-08-23 00:40:04 +08:00
committed by GitHub
parent 98f91d5b88
commit e234ab73c2
12 changed files with 748 additions and 28 deletions
+2 -1
View File
@@ -27,8 +27,9 @@ body:
label: Output format
options:
- SVG
- PNG
- Unicode or ASCII
- Both
- Multiple formats
- Documentation only
validations:
required: true
+4
View File
@@ -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
+15 -5
View File
@@ -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.
![Pretty Mermaid converts Mermaid source into themed SVG and terminal ASCII diagrams](assets/social-preview.png)
![Pretty Mermaid converts Mermaid source into themed SVG, PNG, and terminal ASCII diagrams](assets/social-preview.png)
[![skills.sh](https://skills.sh/b/imxv/pretty-mermaid-skills)](https://www.skills.sh/imxv/pretty-mermaid-skills/pretty-mermaid)
[![CI](https://github.com/imxv/Pretty-mermaid-skills/actions/workflows/ci.yml/badge.svg)](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
View File
@@ -4,9 +4,9 @@
**为 AI Agent 打造的精美 Mermaid 图表**
将 Mermaid 源码转换为精美 SVG 与终端友好的 ASCII——本地运行无需浏览器。
将 Mermaid 源码转换为精美 SVG、便于分享的 PNG 与终端友好的 ASCII——本地运行无需浏览器。
![Pretty Mermaid 将 Mermaid 源码转换为带主题的 SVG 和终端 ASCII 图表](assets/social-preview.png)
![Pretty Mermaid 将 Mermaid 源码转换为带主题的 SVG、PNG 和终端 ASCII 图表](assets/social-preview.png)
[![skills.sh](https://skills.sh/b/imxv/pretty-mermaid-skills)](https://www.skills.sh/imxv/pretty-mermaid-skills/pretty-mermaid)
[![CI](https://github.com/imxv/Pretty-mermaid-skills/actions/workflows/ci.yml/badge.svg)](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
View File
@@ -4,9 +4,9 @@
**AI エージェントのための美しい Mermaid ダイアグラム**
Mermaid ソースを洗練された SVGターミナル向け ASCII に変換します。ローカルで動作し、ブラウザーは不要です。
Mermaid ソースを洗練された SVG、共有しやすい PNG、ターミナル向け ASCII に変換します。ローカルで動作し、ブラウザーは不要です。
![Pretty Mermaid が Mermaid ソースをテーマ付き SVGターミナル ASCII に変換する例](assets/social-preview.png)
![Pretty Mermaid が Mermaid ソースをテーマ付き SVG、PNG、ターミナル ASCII に変換する例](assets/social-preview.png)
[![skills.sh](https://skills.sh/b/imxv/pretty-mermaid-skills)](https://www.skills.sh/imxv/pretty-mermaid-skills/pretty-mermaid)
[![CI](https://github.com/imxv/Pretty-mermaid-skills/actions/workflows/ci.yml/badge.svg)](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 種類のダイアグラム、バッチ処理
## ✨ 主な機能
- 📊 **複数形式**SVGASCII/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 カラーのターミナル出力にも対応します。
## 📚 ドキュメント
+23 -3
View File
@@ -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
+216
View File
@@ -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",
+1
View File
@@ -17,6 +17,7 @@
"validate": "node scripts/validate-docs.mjs"
},
"dependencies": {
"@resvg/resvg-js": "^2.6.2",
"beautiful-mermaid": "^1.1.3"
}
}
+19 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.`);