structviz 0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structviz-0.2/PKG-INFO +205 -0
- structviz-0.2/README.md +192 -0
- structviz-0.2/pyproject.toml +24 -0
- structviz-0.2/setup.cfg +4 -0
- structviz-0.2/structviz/__init__.py +1 -0
- structviz-0.2/structviz/cli.py +92 -0
- structviz-0.2/structviz/d2_render.py +169 -0
- structviz-0.2/structviz/excalidraw_render.py +213 -0
- structviz-0.2/structviz/generators/__init__.py +4 -0
- structviz-0.2/structviz/generators/d2.py +196 -0
- structviz-0.2/structviz/generators/equity.py +38 -0
- structviz-0.2/structviz/generators/excalidraw.py +281 -0
- structviz-0.2/structviz/generators/org.py +300 -0
- structviz-0.2/structviz/html_wrapper.py +31 -0
- structviz-0.2/structviz/models.py +82 -0
- structviz-0.2/structviz/render.py +20 -0
- structviz-0.2/structviz/themes.py +99 -0
- structviz-0.2/structviz.egg-info/PKG-INFO +205 -0
- structviz-0.2/structviz.egg-info/SOURCES.txt +21 -0
- structviz-0.2/structviz.egg-info/dependency_links.txt +1 -0
- structviz-0.2/structviz.egg-info/entry_points.txt +2 -0
- structviz-0.2/structviz.egg-info/requires.txt +5 -0
- structviz-0.2/structviz.egg-info/top_level.txt +1 -0
structviz-0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structviz
|
|
3
|
+
Version: 0.2
|
|
4
|
+
Summary: 声明式股权图与组织架构图生成工具 — YAML 驱动,D2/Graphviz/Excalidraw 三引擎
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: graphviz>=0.20
|
|
11
|
+
Requires-Dist: click>=8.0
|
|
12
|
+
Requires-Dist: jinja2>=3.0
|
|
13
|
+
|
|
14
|
+
# StructViz
|
|
15
|
+
|
|
16
|
+
声明式股权图与组织架构图生成工具。YAML 定义结构,一键输出 PNG / PDF / SVG / HTML / Excalidraw 图表。
|
|
17
|
+
|
|
18
|
+
双引擎:**D2**(直角线 SVG) + **Graphviz DOT**(自动布局) + **Excalidraw**(精确定位)。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# D2 引擎
|
|
24
|
+
sudo pacman -S d2 # Arch
|
|
25
|
+
# 或从 https://github.com/terrastruct/d2/releases 下载
|
|
26
|
+
|
|
27
|
+
# DOT 引擎
|
|
28
|
+
sudo pacman -S graphviz # Arch
|
|
29
|
+
sudo apt install graphviz # Debian
|
|
30
|
+
|
|
31
|
+
# Python 包
|
|
32
|
+
pip install structviz
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 快速开始
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# D2 引擎(直角线 SVG)
|
|
39
|
+
structviz examples/org_siku.yaml -t org -f d2 -o output/
|
|
40
|
+
|
|
41
|
+
# DOT 引擎
|
|
42
|
+
structviz examples/org_bingong.yaml -t org -f png,pdf -p a4 -o output/
|
|
43
|
+
|
|
44
|
+
# Excalidraw 引擎
|
|
45
|
+
structviz examples/org_bingong.yaml -t org -f excalidraw -o output/
|
|
46
|
+
|
|
47
|
+
# 股权图
|
|
48
|
+
structviz examples/equity_sample.yaml -t equity -f png -o output/
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## CLI
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
structviz YAML_FILE [OPTIONS]
|
|
55
|
+
|
|
56
|
+
Options:
|
|
57
|
+
-t, --type [equity|org] 图表类型 (必填)
|
|
58
|
+
-f, --formats TEXT 输出格式: png,pdf,svg,html,excalidraw,d2
|
|
59
|
+
-o, --output TEXT 输出目录
|
|
60
|
+
-p, --papersize TEXT 纸张: a4|a3|letter 或自定义 "W,H[mm|in]"
|
|
61
|
+
-d, --dpi INTEGER 输出 DPI
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## YAML 结构
|
|
65
|
+
|
|
66
|
+
### 组织架构图 (org)
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
meta:
|
|
70
|
+
type: org
|
|
71
|
+
title: "组织架构"
|
|
72
|
+
theme: corporate
|
|
73
|
+
papersize: a4
|
|
74
|
+
dpi: 150
|
|
75
|
+
layout: # 全局布局参数
|
|
76
|
+
max_columns: 3 # 底族每行最多数
|
|
77
|
+
nodesep: 0.3 # 同行节点间距 (英寸)
|
|
78
|
+
ranksep: 0.6 # 行间距 (英寸)
|
|
79
|
+
block_pad: 0.15 # block 内边距
|
|
80
|
+
row_gap: 1 # 换行间距 (minlen)
|
|
81
|
+
# D2 专用
|
|
82
|
+
deputy_gap: 40 # 中心领导框内间距
|
|
83
|
+
wrap_width: 12 # 部门名自动换行宽度(0=不换)
|
|
84
|
+
dept_gap: 6 # 板块内部门间距
|
|
85
|
+
dept_width: 150 # 部门节点宽度
|
|
86
|
+
|
|
87
|
+
groups:
|
|
88
|
+
- level: 0
|
|
89
|
+
label: "权力机构"
|
|
90
|
+
gap: 0.8 # 到下层间距
|
|
91
|
+
nodes:
|
|
92
|
+
- id: 股东大会
|
|
93
|
+
title: "股东大会"
|
|
94
|
+
style: large # large | default | external
|
|
95
|
+
|
|
96
|
+
- level: 1
|
|
97
|
+
label: "决策层"
|
|
98
|
+
blocks:
|
|
99
|
+
- id: 专业委员会
|
|
100
|
+
position: right # right=同行右侧 | bottom=下一行 | left=同行左侧
|
|
101
|
+
max_columns: 3 # 块内每行节点数
|
|
102
|
+
block_align: left # left | center
|
|
103
|
+
edge_from: 董事会 # 箭头到块边框
|
|
104
|
+
nodes:
|
|
105
|
+
- id: 战略委员会
|
|
106
|
+
title: "战略委员会"
|
|
107
|
+
|
|
108
|
+
- level: 2
|
|
109
|
+
gap: 1.5
|
|
110
|
+
nodesep: 3 # 本层节点间距 (覆盖全局)
|
|
111
|
+
nodes:
|
|
112
|
+
- id: 业务运营
|
|
113
|
+
title: "业务运营"
|
|
114
|
+
style: large
|
|
115
|
+
reports_to: 总经理 # 自动连线
|
|
116
|
+
|
|
117
|
+
- level: 3
|
|
118
|
+
blocks:
|
|
119
|
+
- id: 业务运营板块
|
|
120
|
+
position: bottom
|
|
121
|
+
max_columns: 2
|
|
122
|
+
edge_from: 业务运营
|
|
123
|
+
nodes:
|
|
124
|
+
- id: 债贷业务部
|
|
125
|
+
title: "债贷业务部"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### OrgNode 字段
|
|
129
|
+
|
|
130
|
+
| 字段 | 说明 |
|
|
131
|
+
|------|------|
|
|
132
|
+
| `id` | 唯一标识 |
|
|
133
|
+
| `title` | 显示标题,`\n` 换行 |
|
|
134
|
+
| `name` | 副标题 (可选) |
|
|
135
|
+
| `role` | 小字职务 (可选) |
|
|
136
|
+
| `style` | `large`(居中加粗) / `default` / `external`(橙色) |
|
|
137
|
+
| `reports_to` | 上级节点 id |
|
|
138
|
+
| `is_cluster_head` | 板块标题 (仅 block 内) |
|
|
139
|
+
|
|
140
|
+
### OrgGroup 布局参数
|
|
141
|
+
|
|
142
|
+
| 字段 | 说明 |
|
|
143
|
+
|------|------|
|
|
144
|
+
| `gap` | 到下层垂直间距 |
|
|
145
|
+
| `offset` | 水平偏移 (英寸) |
|
|
146
|
+
| `nodesep` | 本层节点间距 (覆盖全局) |
|
|
147
|
+
| `max_columns` | blocks 每行数 (覆盖全局) |
|
|
148
|
+
| `max_right_columns` | right_nodes 每行数 |
|
|
149
|
+
|
|
150
|
+
### OrgBlock 字段
|
|
151
|
+
|
|
152
|
+
| 字段 | 说明 |
|
|
153
|
+
|------|------|
|
|
154
|
+
| `position` | `right` / `bottom` / `left` |
|
|
155
|
+
| `max_columns` | 块内每行节点数 |
|
|
156
|
+
| `row_gap` | 块内换行间距 (minlen) |
|
|
157
|
+
| `block_align` | `left` / `center` |
|
|
158
|
+
| `edge_from` | 箭头源节点 id |
|
|
159
|
+
|
|
160
|
+
## 纸张尺寸
|
|
161
|
+
|
|
162
|
+
| 预设 | 尺寸 |
|
|
163
|
+
|------|------|
|
|
164
|
+
| a4 | 210×297mm |
|
|
165
|
+
| a3 | 297×420mm |
|
|
166
|
+
| letter | 8.5×11in |
|
|
167
|
+
| 自定义 | `"200,260"` (mm) 或 `"8.5,11in"` |
|
|
168
|
+
|
|
169
|
+
## 输出文件
|
|
170
|
+
|
|
171
|
+
| 引擎 | 格式 | 文件 |
|
|
172
|
+
|------|------|------|
|
|
173
|
+
| D2 | d2 | `name.d2` (源码) + `name_d2.svg` |
|
|
174
|
+
| DOT | png, pdf, svg | `name.png`, `name.svg` |
|
|
175
|
+
| DOT | html | `name.html` |
|
|
176
|
+
| Excalidraw | excalidraw | `name.excalidraw` + `name_ex.png` |
|
|
177
|
+
|
|
178
|
+
## 项目结构
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
structviz/
|
|
182
|
+
├── pyproject.toml
|
|
183
|
+
├── README.md
|
|
184
|
+
├── examples/
|
|
185
|
+
│ ├── equity_sample.yaml
|
|
186
|
+
│ ├── org_siku.yaml
|
|
187
|
+
│ └── org_bingong.yaml
|
|
188
|
+
└── structviz/
|
|
189
|
+
├── models.py # Pydantic 模型
|
|
190
|
+
├── themes.py # 主题 + 纸张
|
|
191
|
+
├── cli.py # CLI 入口
|
|
192
|
+
├── render.py # DOT 渲染
|
|
193
|
+
├── d2_render.py # D2 渲染 + 直角后处理
|
|
194
|
+
├── html_wrapper.py # HTML 包装
|
|
195
|
+
├── excalidraw_render.py # Excalidraw → PNG
|
|
196
|
+
└── generators/
|
|
197
|
+
├── equity.py # 股权图 DOT
|
|
198
|
+
├── org.py # 组织架构 DOT
|
|
199
|
+
├── d2.py # 组织架构 D2
|
|
200
|
+
└── excalidraw.py # 组织架构 Excalidraw
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## 许可
|
|
204
|
+
|
|
205
|
+
MIT
|
structviz-0.2/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# StructViz
|
|
2
|
+
|
|
3
|
+
声明式股权图与组织架构图生成工具。YAML 定义结构,一键输出 PNG / PDF / SVG / HTML / Excalidraw 图表。
|
|
4
|
+
|
|
5
|
+
双引擎:**D2**(直角线 SVG) + **Graphviz DOT**(自动布局) + **Excalidraw**(精确定位)。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# D2 引擎
|
|
11
|
+
sudo pacman -S d2 # Arch
|
|
12
|
+
# 或从 https://github.com/terrastruct/d2/releases 下载
|
|
13
|
+
|
|
14
|
+
# DOT 引擎
|
|
15
|
+
sudo pacman -S graphviz # Arch
|
|
16
|
+
sudo apt install graphviz # Debian
|
|
17
|
+
|
|
18
|
+
# Python 包
|
|
19
|
+
pip install structviz
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 快速开始
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# D2 引擎(直角线 SVG)
|
|
26
|
+
structviz examples/org_siku.yaml -t org -f d2 -o output/
|
|
27
|
+
|
|
28
|
+
# DOT 引擎
|
|
29
|
+
structviz examples/org_bingong.yaml -t org -f png,pdf -p a4 -o output/
|
|
30
|
+
|
|
31
|
+
# Excalidraw 引擎
|
|
32
|
+
structviz examples/org_bingong.yaml -t org -f excalidraw -o output/
|
|
33
|
+
|
|
34
|
+
# 股权图
|
|
35
|
+
structviz examples/equity_sample.yaml -t equity -f png -o output/
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## CLI
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
structviz YAML_FILE [OPTIONS]
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
-t, --type [equity|org] 图表类型 (必填)
|
|
45
|
+
-f, --formats TEXT 输出格式: png,pdf,svg,html,excalidraw,d2
|
|
46
|
+
-o, --output TEXT 输出目录
|
|
47
|
+
-p, --papersize TEXT 纸张: a4|a3|letter 或自定义 "W,H[mm|in]"
|
|
48
|
+
-d, --dpi INTEGER 输出 DPI
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## YAML 结构
|
|
52
|
+
|
|
53
|
+
### 组织架构图 (org)
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
meta:
|
|
57
|
+
type: org
|
|
58
|
+
title: "组织架构"
|
|
59
|
+
theme: corporate
|
|
60
|
+
papersize: a4
|
|
61
|
+
dpi: 150
|
|
62
|
+
layout: # 全局布局参数
|
|
63
|
+
max_columns: 3 # 底族每行最多数
|
|
64
|
+
nodesep: 0.3 # 同行节点间距 (英寸)
|
|
65
|
+
ranksep: 0.6 # 行间距 (英寸)
|
|
66
|
+
block_pad: 0.15 # block 内边距
|
|
67
|
+
row_gap: 1 # 换行间距 (minlen)
|
|
68
|
+
# D2 专用
|
|
69
|
+
deputy_gap: 40 # 中心领导框内间距
|
|
70
|
+
wrap_width: 12 # 部门名自动换行宽度(0=不换)
|
|
71
|
+
dept_gap: 6 # 板块内部门间距
|
|
72
|
+
dept_width: 150 # 部门节点宽度
|
|
73
|
+
|
|
74
|
+
groups:
|
|
75
|
+
- level: 0
|
|
76
|
+
label: "权力机构"
|
|
77
|
+
gap: 0.8 # 到下层间距
|
|
78
|
+
nodes:
|
|
79
|
+
- id: 股东大会
|
|
80
|
+
title: "股东大会"
|
|
81
|
+
style: large # large | default | external
|
|
82
|
+
|
|
83
|
+
- level: 1
|
|
84
|
+
label: "决策层"
|
|
85
|
+
blocks:
|
|
86
|
+
- id: 专业委员会
|
|
87
|
+
position: right # right=同行右侧 | bottom=下一行 | left=同行左侧
|
|
88
|
+
max_columns: 3 # 块内每行节点数
|
|
89
|
+
block_align: left # left | center
|
|
90
|
+
edge_from: 董事会 # 箭头到块边框
|
|
91
|
+
nodes:
|
|
92
|
+
- id: 战略委员会
|
|
93
|
+
title: "战略委员会"
|
|
94
|
+
|
|
95
|
+
- level: 2
|
|
96
|
+
gap: 1.5
|
|
97
|
+
nodesep: 3 # 本层节点间距 (覆盖全局)
|
|
98
|
+
nodes:
|
|
99
|
+
- id: 业务运营
|
|
100
|
+
title: "业务运营"
|
|
101
|
+
style: large
|
|
102
|
+
reports_to: 总经理 # 自动连线
|
|
103
|
+
|
|
104
|
+
- level: 3
|
|
105
|
+
blocks:
|
|
106
|
+
- id: 业务运营板块
|
|
107
|
+
position: bottom
|
|
108
|
+
max_columns: 2
|
|
109
|
+
edge_from: 业务运营
|
|
110
|
+
nodes:
|
|
111
|
+
- id: 债贷业务部
|
|
112
|
+
title: "债贷业务部"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### OrgNode 字段
|
|
116
|
+
|
|
117
|
+
| 字段 | 说明 |
|
|
118
|
+
|------|------|
|
|
119
|
+
| `id` | 唯一标识 |
|
|
120
|
+
| `title` | 显示标题,`\n` 换行 |
|
|
121
|
+
| `name` | 副标题 (可选) |
|
|
122
|
+
| `role` | 小字职务 (可选) |
|
|
123
|
+
| `style` | `large`(居中加粗) / `default` / `external`(橙色) |
|
|
124
|
+
| `reports_to` | 上级节点 id |
|
|
125
|
+
| `is_cluster_head` | 板块标题 (仅 block 内) |
|
|
126
|
+
|
|
127
|
+
### OrgGroup 布局参数
|
|
128
|
+
|
|
129
|
+
| 字段 | 说明 |
|
|
130
|
+
|------|------|
|
|
131
|
+
| `gap` | 到下层垂直间距 |
|
|
132
|
+
| `offset` | 水平偏移 (英寸) |
|
|
133
|
+
| `nodesep` | 本层节点间距 (覆盖全局) |
|
|
134
|
+
| `max_columns` | blocks 每行数 (覆盖全局) |
|
|
135
|
+
| `max_right_columns` | right_nodes 每行数 |
|
|
136
|
+
|
|
137
|
+
### OrgBlock 字段
|
|
138
|
+
|
|
139
|
+
| 字段 | 说明 |
|
|
140
|
+
|------|------|
|
|
141
|
+
| `position` | `right` / `bottom` / `left` |
|
|
142
|
+
| `max_columns` | 块内每行节点数 |
|
|
143
|
+
| `row_gap` | 块内换行间距 (minlen) |
|
|
144
|
+
| `block_align` | `left` / `center` |
|
|
145
|
+
| `edge_from` | 箭头源节点 id |
|
|
146
|
+
|
|
147
|
+
## 纸张尺寸
|
|
148
|
+
|
|
149
|
+
| 预设 | 尺寸 |
|
|
150
|
+
|------|------|
|
|
151
|
+
| a4 | 210×297mm |
|
|
152
|
+
| a3 | 297×420mm |
|
|
153
|
+
| letter | 8.5×11in |
|
|
154
|
+
| 自定义 | `"200,260"` (mm) 或 `"8.5,11in"` |
|
|
155
|
+
|
|
156
|
+
## 输出文件
|
|
157
|
+
|
|
158
|
+
| 引擎 | 格式 | 文件 |
|
|
159
|
+
|------|------|------|
|
|
160
|
+
| D2 | d2 | `name.d2` (源码) + `name_d2.svg` |
|
|
161
|
+
| DOT | png, pdf, svg | `name.png`, `name.svg` |
|
|
162
|
+
| DOT | html | `name.html` |
|
|
163
|
+
| Excalidraw | excalidraw | `name.excalidraw` + `name_ex.png` |
|
|
164
|
+
|
|
165
|
+
## 项目结构
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
structviz/
|
|
169
|
+
├── pyproject.toml
|
|
170
|
+
├── README.md
|
|
171
|
+
├── examples/
|
|
172
|
+
│ ├── equity_sample.yaml
|
|
173
|
+
│ ├── org_siku.yaml
|
|
174
|
+
│ └── org_bingong.yaml
|
|
175
|
+
└── structviz/
|
|
176
|
+
├── models.py # Pydantic 模型
|
|
177
|
+
├── themes.py # 主题 + 纸张
|
|
178
|
+
├── cli.py # CLI 入口
|
|
179
|
+
├── render.py # DOT 渲染
|
|
180
|
+
├── d2_render.py # D2 渲染 + 直角后处理
|
|
181
|
+
├── html_wrapper.py # HTML 包装
|
|
182
|
+
├── excalidraw_render.py # Excalidraw → PNG
|
|
183
|
+
└── generators/
|
|
184
|
+
├── equity.py # 股权图 DOT
|
|
185
|
+
├── org.py # 组织架构 DOT
|
|
186
|
+
├── d2.py # 组织架构 D2
|
|
187
|
+
└── excalidraw.py # 组织架构 Excalidraw
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## 许可
|
|
191
|
+
|
|
192
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "structviz"
|
|
7
|
+
version = "0.2"
|
|
8
|
+
description = "声明式股权图与组织架构图生成工具 — YAML 驱动,D2/Graphviz/Excalidraw 三引擎"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pyyaml>=6.0",
|
|
14
|
+
"pydantic>=2.0",
|
|
15
|
+
"graphviz>=0.20",
|
|
16
|
+
"click>=8.0",
|
|
17
|
+
"jinja2>=3.0"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
structviz = "structviz.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["structviz*"]
|
structviz-0.2/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import yaml
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
from .models import EquityChart, OrgChart
|
|
6
|
+
from .generators import generate_equity_dot, generate_org_dot, generate_org_excalidraw, generate_org_d2
|
|
7
|
+
from .render import render_dot
|
|
8
|
+
from .html_wrapper import generate_html
|
|
9
|
+
from .excalidraw_render import render_excalidraw_to_png
|
|
10
|
+
from .d2_render import render_d2
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument('yaml_file', type=click.Path(exists=True))
|
|
14
|
+
@click.option('-t', '--type', 'chart_type', required=True,
|
|
15
|
+
type=click.Choice(['equity', 'org']), help='Chart type')
|
|
16
|
+
@click.option('-f', '--formats', default='png',
|
|
17
|
+
help='Comma-separated output formats (png,pdf,svg,html,excalidraw,d2)')
|
|
18
|
+
@click.option('-o', '--output', default='output/', help='Output directory')
|
|
19
|
+
@click.option('-p', '--papersize', default=None,
|
|
20
|
+
help='Paper preset (a4/a3/letter) or custom "W,H[mm|in]" '
|
|
21
|
+
'e.g. "210,297" or "8.5,11in" (overrides YAML)')
|
|
22
|
+
@click.option('-d', '--dpi', default=None, type=int,
|
|
23
|
+
help='Output DPI (overrides YAML meta.dpi)')
|
|
24
|
+
def main(yaml_file, chart_type, formats, output, papersize, dpi):
|
|
25
|
+
"""Generate charts from YAML definition."""
|
|
26
|
+
with open(yaml_file, 'r', encoding='utf-8') as f:
|
|
27
|
+
data = yaml.safe_load(f)
|
|
28
|
+
|
|
29
|
+
os.makedirs(output, exist_ok=True)
|
|
30
|
+
base_name = os.path.splitext(os.path.basename(yaml_file))[0]
|
|
31
|
+
output_base = os.path.join(output, base_name)
|
|
32
|
+
|
|
33
|
+
if chart_type == 'equity':
|
|
34
|
+
chart = EquityChart(**data)
|
|
35
|
+
elif chart_type == 'org':
|
|
36
|
+
chart = OrgChart(**data)
|
|
37
|
+
else:
|
|
38
|
+
raise click.BadParameter("type must be equity or org")
|
|
39
|
+
|
|
40
|
+
fmt_list = [f.strip() for f in formats.split(',')]
|
|
41
|
+
|
|
42
|
+
# Excalidraw path
|
|
43
|
+
if 'excalidraw' in fmt_list:
|
|
44
|
+
if chart_type == 'org':
|
|
45
|
+
json_str = generate_org_excalidraw(chart, paper_size=papersize or chart.meta.papersize,
|
|
46
|
+
dpi=dpi or chart.meta.dpi)
|
|
47
|
+
path = output_base + '.excalidraw'
|
|
48
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
49
|
+
f.write(json_str)
|
|
50
|
+
png_path = output_base + '_ex.png'
|
|
51
|
+
render_excalidraw_to_png(json_str, png_path)
|
|
52
|
+
click.echo(f"Excalidraw saved to {path} + {png_path}")
|
|
53
|
+
else:
|
|
54
|
+
click.echo("Excalidraw only supports org charts currently.")
|
|
55
|
+
|
|
56
|
+
# D2 path
|
|
57
|
+
if 'd2' in fmt_list and chart_type == 'org':
|
|
58
|
+
d2_source = generate_org_d2(chart)
|
|
59
|
+
d2_path = output_base + '.d2'
|
|
60
|
+
with open(d2_path, 'w', encoding='utf-8') as f:
|
|
61
|
+
f.write(d2_source)
|
|
62
|
+
svg_path = render_d2(d2_source, output_base + '_d2.svg')
|
|
63
|
+
click.echo(f"D2 saved to {d2_path} + {svg_path}")
|
|
64
|
+
|
|
65
|
+
# DOT path (png, pdf, svg, html)
|
|
66
|
+
dot_formats = [f for f in fmt_list if f not in ('excalidraw', 'html', 'd2')]
|
|
67
|
+
if dot_formats:
|
|
68
|
+
if chart_type == 'equity':
|
|
69
|
+
dot_source = generate_equity_dot(chart, paper_size=papersize or chart.meta.papersize,
|
|
70
|
+
dpi=dpi or chart.meta.dpi)
|
|
71
|
+
else:
|
|
72
|
+
dot_source = generate_org_dot(chart, paper_size=papersize or chart.meta.papersize,
|
|
73
|
+
dpi=dpi or chart.meta.dpi)
|
|
74
|
+
render_dot(dot_source, output_base, dot_formats)
|
|
75
|
+
|
|
76
|
+
if 'html' in fmt_list:
|
|
77
|
+
svg_path = output_base + '.svg'
|
|
78
|
+
if not os.path.exists(svg_path):
|
|
79
|
+
if chart_type == 'equity':
|
|
80
|
+
ds = generate_equity_dot(chart, paper_size=papersize or chart.meta.papersize,
|
|
81
|
+
dpi=dpi or chart.meta.dpi)
|
|
82
|
+
else:
|
|
83
|
+
ds = generate_org_dot(chart, paper_size=papersize or chart.meta.papersize,
|
|
84
|
+
dpi=dpi or chart.meta.dpi)
|
|
85
|
+
render_dot(ds, output_base, ['svg'])
|
|
86
|
+
generate_html(svg_path, chart.meta.title, output_base + '.html')
|
|
87
|
+
|
|
88
|
+
if dot_formats or 'html' in fmt_list:
|
|
89
|
+
click.echo(f"Chart generated in {output}")
|
|
90
|
+
|
|
91
|
+
if __name__ == '__main__':
|
|
92
|
+
main()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""D2 渲染器:编译 D2 源码 → SVG,含直角线后处理"""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_d2(d2_source: str, output_path: str, cleanup: bool = True) -> str:
|
|
11
|
+
"""编译 D2 源码到 SVG,返回输出文件路径"""
|
|
12
|
+
# 写临时文件
|
|
13
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.d2', delete=False) as f:
|
|
14
|
+
tmp_d2 = f.name
|
|
15
|
+
f.write(d2_source)
|
|
16
|
+
|
|
17
|
+
svg_path = output_path
|
|
18
|
+
if not svg_path.endswith('.svg'):
|
|
19
|
+
svg_path = output_path + '.svg'
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
['d2', '--pad=0', '--layout=elk', tmp_d2, svg_path],
|
|
24
|
+
capture_output=True, text=True, timeout=60
|
|
25
|
+
)
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
raise RuntimeError(f"d2 compile failed: {result.stderr}")
|
|
28
|
+
|
|
29
|
+
# SVG 后处理
|
|
30
|
+
_post_process_svg(svg_path)
|
|
31
|
+
|
|
32
|
+
finally:
|
|
33
|
+
if cleanup:
|
|
34
|
+
os.unlink(tmp_d2)
|
|
35
|
+
|
|
36
|
+
return svg_path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _flatten_tspan(content: str) -> str:
|
|
40
|
+
"""把 <text> 内的 <tspan> 多行拆成独立 <text>,兼容 resvg"""
|
|
41
|
+
def replace_tspan(match):
|
|
42
|
+
full = match.group(0)
|
|
43
|
+
attrs = match.group(1)
|
|
44
|
+
# 提取属性
|
|
45
|
+
x_val = re.search(r'x="([^"]*)"', attrs).group(1)
|
|
46
|
+
y_val = re.search(r'y="([^"]*)"', attrs).group(1)
|
|
47
|
+
fill = re.search(r'fill="([^"]*)"', attrs)
|
|
48
|
+
fill = fill.group(1) if fill else '#0A0F25'
|
|
49
|
+
base_y = float(y_val)
|
|
50
|
+
|
|
51
|
+
# 保留 class 和 style
|
|
52
|
+
cls = re.search(r'class="([^"]*)"', attrs)
|
|
53
|
+
style = re.search(r'style="([^"]*)"', attrs)
|
|
54
|
+
extra = f'class="{cls.group(1)}"' if cls else ''
|
|
55
|
+
if style:
|
|
56
|
+
extra += f' style="{style.group(1)}"'
|
|
57
|
+
ta = re.search(r'text-anchor:(\w+)', style.group(1))
|
|
58
|
+
anchor = ta.group(1) if ta else 'middle'
|
|
59
|
+
else:
|
|
60
|
+
anchor = 'middle'
|
|
61
|
+
|
|
62
|
+
# 逐行生成独立 <text>
|
|
63
|
+
result = []
|
|
64
|
+
current_y = base_y
|
|
65
|
+
for ts in re.finditer(r'<tspan[^>]*dy="([^"]*)"[^>]*>([^<]*)</tspan>', full):
|
|
66
|
+
current_y += float(ts.group(1))
|
|
67
|
+
result.append(
|
|
68
|
+
f'<text x="{x_val}" y="{current_y:.1f}" fill="{fill}" '
|
|
69
|
+
f'{extra} text-anchor="{anchor}">{ts.group(2)}</text>'
|
|
70
|
+
)
|
|
71
|
+
return '\n'.join(result) if result else full
|
|
72
|
+
|
|
73
|
+
# 逐个 <text>...</text> 处理,只替换含 <tspan> 的
|
|
74
|
+
def patch(s):
|
|
75
|
+
if '<tspan' not in s:
|
|
76
|
+
return s
|
|
77
|
+
m = re.match(r'<text(\s[^>]*)>(.*?)</text>', s, re.DOTALL)
|
|
78
|
+
if m:
|
|
79
|
+
return replace_tspan(m)
|
|
80
|
+
return s
|
|
81
|
+
|
|
82
|
+
return re.sub(
|
|
83
|
+
r'<text\s[^>]*>.*?</text>',
|
|
84
|
+
lambda m: patch(m.group(0)),
|
|
85
|
+
content,
|
|
86
|
+
flags=re.DOTALL
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _post_process_svg(svg_path: str):
|
|
91
|
+
"""SVG 后处理:去嵌套、去 legend、斜线转直角"""
|
|
92
|
+
with open(svg_path, 'r', encoding='utf-8') as f:
|
|
93
|
+
content = f.read()
|
|
94
|
+
|
|
95
|
+
# 1. 剥离 XML 声明
|
|
96
|
+
xml_decl = ''
|
|
97
|
+
if content.startswith('<?xml'):
|
|
98
|
+
xml_end = content.index('?>') + 2
|
|
99
|
+
xml_decl = content[:xml_end]
|
|
100
|
+
content = content[xml_end:]
|
|
101
|
+
|
|
102
|
+
# 2. 去外层 SVG,把 xmlns 挪到内层
|
|
103
|
+
outer = re.match(r'<svg[^>]*>', content).group(0)
|
|
104
|
+
# 提取所有 xmlns 属性
|
|
105
|
+
xmlns_attrs = ' '.join(re.findall(r'xmlns(?::\w+)?="[^"]*"', outer))
|
|
106
|
+
content = re.sub(r'^<svg[^>]*>', '', content, count=1)
|
|
107
|
+
# 给内层 SVG 补上 xmlns
|
|
108
|
+
content = re.sub(r'^(<svg)\b', r'\1 ' + xmlns_attrs + ' ', content, count=1)
|
|
109
|
+
content = re.sub(r'</svg>$', '', content)
|
|
110
|
+
|
|
111
|
+
# 3. 删除 layout/routing 图例
|
|
112
|
+
for legend_class in ['bGF5b3V0', 'cm91dGluZw==']:
|
|
113
|
+
while True:
|
|
114
|
+
pattern = f'<g class="{legend_class}">'
|
|
115
|
+
idx = content.find(pattern)
|
|
116
|
+
if idx == -1:
|
|
117
|
+
break
|
|
118
|
+
depth = 1
|
|
119
|
+
pos = idx + len(pattern)
|
|
120
|
+
while depth > 0:
|
|
121
|
+
ot = content.find('<g ', pos)
|
|
122
|
+
ct = content.find('</g>', pos)
|
|
123
|
+
if ot >= 0 and ot < ct:
|
|
124
|
+
depth += 1
|
|
125
|
+
pos = ot + 3
|
|
126
|
+
else:
|
|
127
|
+
depth -= 1
|
|
128
|
+
pos = ct + 4
|
|
129
|
+
content = content[:idx] + content[pos:]
|
|
130
|
+
|
|
131
|
+
# 4. tspan 多行拆成独立 <text>(兼容 resvg)
|
|
132
|
+
content = _flatten_tspan(content)
|
|
133
|
+
|
|
134
|
+
# 5. 查找板块中心 x(font-size:24px 的容器标题)
|
|
135
|
+
plate_centers = {}
|
|
136
|
+
for m in re.finditer(r'<text x="([^"]*)" y="([^"]*)"[^>]*font-size:24px[^>]*>([^<]*)</text>', content):
|
|
137
|
+
plate_centers[m.group(3)] = float(m.group(1))
|
|
138
|
+
|
|
139
|
+
# 5. 连接线转直角 + 终点对齐板块中心
|
|
140
|
+
def fix_path(match):
|
|
141
|
+
d = match.group(1)
|
|
142
|
+
parts = d.split()
|
|
143
|
+
if len(parts) != 6 or parts[0] != 'M' or parts[3] != 'L':
|
|
144
|
+
return match.group(0)
|
|
145
|
+
|
|
146
|
+
x1, y1 = float(parts[1]), float(parts[2])
|
|
147
|
+
x2, y2 = float(parts[4]), float(parts[5])
|
|
148
|
+
y_mid = (y1 + y2) / 2
|
|
149
|
+
|
|
150
|
+
# 只对到达板块区域的连线(y2 > 0)做中心对齐
|
|
151
|
+
if y2 > 0 and plate_centers:
|
|
152
|
+
best_dist = 500
|
|
153
|
+
target_x = x2
|
|
154
|
+
for cx in plate_centers.values():
|
|
155
|
+
dist = abs(x2 - cx)
|
|
156
|
+
if dist < best_dist:
|
|
157
|
+
best_dist = dist
|
|
158
|
+
target_x = cx
|
|
159
|
+
else:
|
|
160
|
+
target_x = x2
|
|
161
|
+
|
|
162
|
+
return f'd="M {x1} {y1} L {x1} {y_mid} L {target_x} {y_mid} L {target_x} {y2}"'
|
|
163
|
+
|
|
164
|
+
content = re.sub(r'd="(M [^"]*)"', fix_path, content)
|
|
165
|
+
|
|
166
|
+
# 6. 写回
|
|
167
|
+
content = xml_decl + content
|
|
168
|
+
with open(svg_path, 'w', encoding='utf-8') as f:
|
|
169
|
+
f.write(content)
|