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 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
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)