dsh-drawai 0.4.3 → 0.4.4
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.
- package/README.md +5 -4
- package/lib/client.js +142 -16
- package/lib/index.js +170 -2
- package/lib/motion/mgstyles/briefs/23-ink-wash.md +147 -0
- package/lib/motion/mgstyles/catalog.d/23-ink-wash.json +28 -0
- package/lib/motion/mgstyles/catalog.json +127 -14
- package/lib/motion/mgstyles/upstream/demos/23-ink-wash/brush.js +149 -0
- package/lib/motion/mgstyles/upstream/demos/23-ink-wash/film.js +202 -0
- package/lib/motion/mgstyles/upstream/manifest.json +28 -3
- package/lib/motion/mgstyles/upstream/prompts/23-ink-wash.md +59 -0
- package/lib/motion/project.js +5 -1
- package/lib/motion/qa.js +116 -6
- package/lib/motion/scene-kit.js +1 -1
- package/lib/motion-library.js +1 -1
- package/lib/svg-export.js +1344 -0
- package/package.json +3 -2
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
/* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
|
|
2
|
+
/* DrawAI 宿主侧导出:把文档对象直接画成 SVG(零依赖),PNG 再交给 @resvg/resvg-js。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这条路径(issue #23):导出一直挂在浏览器那一半 ——
|
|
5
|
+
* `diagram_read {render:true}` 与 `diagram_apply {op:"export"}` 都要用户在右侧栏
|
|
6
|
+
* 打开着画布才有人接。QQ / CLI 这类没有侧栏的通道里,宿主能读写 .drawai,
|
|
7
|
+
* 成品图却拿不到,而那时成品图正是唯一的交付物。
|
|
8
|
+
*
|
|
9
|
+
* 这一份是**按同一套规则独立重画**,不是把客户端的渲染器搬过来:客户端是 React
|
|
10
|
+
* 组件树 + 浏览器字体度量 + 画布缩放,宿主两样都没有。所以:
|
|
11
|
+
* · 几何(形状、连线、标签位置、换行、导出包围盒)照抄客户端的纯函数口径;
|
|
12
|
+
* · 文字量宽用 `charWidth`(CJK 按 1em、其余按 0.55em 估算)—— 与客户端
|
|
13
|
+
* **无 DOM 时的回退**同一套,常见标签断行一致,长标签可能差一行;
|
|
14
|
+
* · 视频/卡片/音频这类由客户端组件画的节点只画出通用形状,不还原内部画面。
|
|
15
|
+
* 因此调用方要把保真度标成 `approximate`,并把 warnings 一并交出去。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_FONT,
|
|
20
|
+
arrowFromStyle,
|
|
21
|
+
curvedFromStyle,
|
|
22
|
+
dashPatternFromStyle,
|
|
23
|
+
imageValueFromStyle,
|
|
24
|
+
isOrthogonalEdgeStyle,
|
|
25
|
+
nodeShapeFromStyle,
|
|
26
|
+
sideFromStyle,
|
|
27
|
+
styleGet,
|
|
28
|
+
styleNumber,
|
|
29
|
+
} from './style-kernel.js'
|
|
30
|
+
import { createOrthogonalRouter, storedRoute } from './routing/router.js'
|
|
31
|
+
import { getRoutingRuntime } from './routing/node-runtime.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 没有折点的正交边,用**宿主这一份**路由器补一条真走线。
|
|
35
|
+
*
|
|
36
|
+
* 为什么不干脆按直线画:`edgeStyle=orthogonalEdgeStyle` 的边在画布上从来不是斜线 ——
|
|
37
|
+
* 客户端 `routeEdgeStyled` 会跑一遍自动路由。导出若画成对角线,同一份文档在两个地方
|
|
38
|
+
* 长得不一样,而这正是 issue #23 抱怨的事("拿不到成品图"之后紧接着就是"拿到的图不对")。
|
|
39
|
+
* 引擎是懒加载的 vendor 文件,缺了/坏了就退回 Z 形近似并在 warnings 里说明。
|
|
40
|
+
*/
|
|
41
|
+
const exportRouter = createOrthogonalRouter(getRoutingRuntime)
|
|
42
|
+
|
|
43
|
+
function solveMissingRoutes(edges, boxes, warnings) {
|
|
44
|
+
const need = []
|
|
45
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
46
|
+
const edge = edges[i]
|
|
47
|
+
if (edge === null || typeof edge !== 'object') continue
|
|
48
|
+
if (Array.isArray(edge.points) && edge.points.length > 0) continue
|
|
49
|
+
const style = styleOf(edge)
|
|
50
|
+
if (isOrthogonalEdgeStyle(style) !== true) continue
|
|
51
|
+
if (curvedFromStyle(style) === true) continue
|
|
52
|
+
const fromKey = edge.from === undefined || edge.from === null ? '' : String(edge.from)
|
|
53
|
+
const toKey = edge.to === undefined || edge.to === null ? '' : String(edge.to)
|
|
54
|
+
// 自环(两端同一个节点)由 routeForEdge 单独画:交给避障求解只会得到"两端同一点"。
|
|
55
|
+
if (fromKey.length > 0 && fromKey === toKey) continue
|
|
56
|
+
need.push(Object.assign({}, edge, { from: fromKey.length > 0 ? fromKey : edge.from, to: toKey.length > 0 ? toKey : edge.to }))
|
|
57
|
+
}
|
|
58
|
+
if (need.length === 0) return new Map()
|
|
59
|
+
try {
|
|
60
|
+
const routes = exportRouter.solve(need, boxes)
|
|
61
|
+
const out = new Map()
|
|
62
|
+
for (const id of Object.keys(routes)) {
|
|
63
|
+
const points = routes[id]
|
|
64
|
+
if (Array.isArray(points) && points.length > 0) out.set(id, points)
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
} catch (error) {
|
|
68
|
+
warnings.push(
|
|
69
|
+
'自动避障引擎不可用(' + (error instanceof Error ? error.message : String(error)) + '),没有折点的正交连线按 Z 形近似',
|
|
70
|
+
)
|
|
71
|
+
return new Map()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 与客户端同一套数字(src/client.js:407-461)。改这里就要同步那边,反之亦然。 */
|
|
76
|
+
const GRID = 10
|
|
77
|
+
const FSIZE = 12
|
|
78
|
+
const LHEIGHT = 14
|
|
79
|
+
const FALLBACK_NODE_W = 150
|
|
80
|
+
const FALLBACK_NODE_H = 60
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 导出件里写的 font-family:客户端的 `Helvetica, Arial` 后面补一串常见中文字体。
|
|
84
|
+
*
|
|
85
|
+
* 为什么:画布在浏览器里显示中文靠的是**系统字体回退**(`Helvetica` 里没有汉字,
|
|
86
|
+
* 浏览器按 lang 自己挑一个中文字体)。导出的 SVG 换到没有这一层回退的渲染器里
|
|
87
|
+
* (resvg、Inkscape、某些 CI 的 headless),同一串 `Helvetica, Arial, sans-serif`
|
|
88
|
+
* 就只剩方框。把常见中文系统字体显式列出来,交付物到哪都不至于变豆腐块。
|
|
89
|
+
*
|
|
90
|
+
* 顺序要紧:具名字体必须在 `sans-serif` **之前** —— 通用族一旦匹配上就不会再往后找。
|
|
91
|
+
*
|
|
92
|
+
* 量宽不读这个常量:`textWidth` 是逐字估算(CJK 1em、其余 0.55em,见 `charWidth`),
|
|
93
|
+
* 与渲染时实际命中哪个字体无关,所以换字体不会让断行位置漂移。
|
|
94
|
+
*/
|
|
95
|
+
const SVG_FONT = 'Helvetica, Arial, "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", "Noto Sans CJK SC", "WenQuanYi Micro Hei", sans-serif'
|
|
96
|
+
const ON_DARK_FONT = '#e8e8e8'
|
|
97
|
+
|
|
98
|
+
/** 浅色:导出交付物的缺省外观(白纸黑线),与画布的白底主题一致。 */
|
|
99
|
+
const LIGHT = {
|
|
100
|
+
page: '#ffffff',
|
|
101
|
+
text: '#000000',
|
|
102
|
+
line: '#000000',
|
|
103
|
+
labelBg: '#ffffff',
|
|
104
|
+
default: { fill: '#ffffff', stroke: '#000000' },
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 深色:透明纸 + 浅色字线,贴在深色文档/幻灯片里不突兀。 */
|
|
108
|
+
const DARK = {
|
|
109
|
+
page: 'transparent',
|
|
110
|
+
text: '#e8e8e8',
|
|
111
|
+
line: '#c9c9c9',
|
|
112
|
+
labelBg: '#1c1c1c',
|
|
113
|
+
// 与客户端 src/client.js:462 一致:没写 fillColor 的节点在深色主题下是深灰底浅边,
|
|
114
|
+
// 不是白底 —— 白块贴在深色文档里会像漏画了一层。
|
|
115
|
+
default: { fill: '#2a2a2a', stroke: '#c9c9c9' },
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 导出包围盒四周留白(与客户端 exportGeometry 的缺省一致)。 */
|
|
119
|
+
const DEFAULT_PADDING = 24
|
|
120
|
+
/** 栅格化的硬上限:单边与总像素。超了宁可报错,也不要让 resvg 吃掉几个 GB。 */
|
|
121
|
+
const MAX_SIDE = 16384
|
|
122
|
+
const MAX_PIXELS = 32000000
|
|
123
|
+
|
|
124
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
const numberOr = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback)
|
|
127
|
+
const snapTo = (value, grid) => Math.round(value / grid) * grid
|
|
128
|
+
const fmt = (value) => {
|
|
129
|
+
const n = Math.round(numberOr(value, 0) * 100) / 100
|
|
130
|
+
return String(n)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function escapeXml(text) {
|
|
134
|
+
return String(text)
|
|
135
|
+
.replace(/&/g, '&')
|
|
136
|
+
.replace(/</g, '<')
|
|
137
|
+
.replace(/>/g, '>')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function escapeAttr(text) {
|
|
141
|
+
return escapeXml(text).replace(/"/g, '"').replace(/'/g, ''')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 拼属性:值为 null/undefined/'' 的键直接不写(缺省即不画,与客户端一致)。 */
|
|
145
|
+
function attrs(map) {
|
|
146
|
+
let out = ''
|
|
147
|
+
for (const key of Object.keys(map)) {
|
|
148
|
+
const value = map[key]
|
|
149
|
+
if (value === null || value === undefined || value === '') continue
|
|
150
|
+
out += ' ' + key + '="' + escapeAttr(String(value)) + '"'
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── 文字度量与断行(src/client.js:700-754 的宿主版)──────────────────────
|
|
156
|
+
|
|
157
|
+
function charWidth(ch, size) {
|
|
158
|
+
const code = ch.codePointAt(0)
|
|
159
|
+
const wide =
|
|
160
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
161
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
162
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
163
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
164
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
165
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
166
|
+
(code >= 0xffe0 && code <= 0xffe6)
|
|
167
|
+
return wide ? size : size * 0.55
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function textWidth(text, size) {
|
|
171
|
+
const chars = Array.from(String(text))
|
|
172
|
+
let total = 0
|
|
173
|
+
for (let i = 0; i < chars.length; i += 1) total += charWidth(chars[i], size)
|
|
174
|
+
return total
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function wrapLabel(label, maxWidth, size) {
|
|
178
|
+
const fs = numberOr(size, FSIZE)
|
|
179
|
+
const lines = []
|
|
180
|
+
const paras = String(label).split('\n')
|
|
181
|
+
for (let p = 0; p < paras.length; p += 1) {
|
|
182
|
+
const chars = Array.from(paras[p])
|
|
183
|
+
let current = ''
|
|
184
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
185
|
+
const nextWidth = textWidth(current + chars[i], fs)
|
|
186
|
+
if (nextWidth > maxWidth && current.length > 0) {
|
|
187
|
+
lines.push(current)
|
|
188
|
+
current = ''
|
|
189
|
+
}
|
|
190
|
+
current += chars[i]
|
|
191
|
+
}
|
|
192
|
+
lines.push(current)
|
|
193
|
+
}
|
|
194
|
+
return lines.length > 0 ? lines : ['']
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function labelLineHeight(fontSize) {
|
|
198
|
+
return LHEIGHT * (numberOr(fontSize, FSIZE) / FSIZE)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── 配色(src/client.js:765-813)─────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function parseHexColor(value) {
|
|
204
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
205
|
+
const short = /^#([0-9a-fA-F]{3})$/.exec(text)
|
|
206
|
+
if (short !== null) {
|
|
207
|
+
const h = short[1]
|
|
208
|
+
return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16) }
|
|
209
|
+
}
|
|
210
|
+
const full = /^#([0-9a-fA-F]{6})$/.exec(text)
|
|
211
|
+
if (full !== null) {
|
|
212
|
+
const h = full[1]
|
|
213
|
+
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }
|
|
214
|
+
}
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 缺省字色只看节点自己的填充:浅底黑字、深底浅字(换主题不改文档里的字色)。 */
|
|
219
|
+
function defaultFontOnFill(fill) {
|
|
220
|
+
const rgb = parseHexColor(fill)
|
|
221
|
+
if (rgb === null) return DEFAULT_FONT
|
|
222
|
+
const luma = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
|
|
223
|
+
return luma > 0.55 ? DEFAULT_FONT : ON_DARK_FONT
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `#rrggbbaa` 拆成颜色 + 透明度(SVG 的 fill 不认 8 位十六进制)。 */
|
|
227
|
+
function parseCssColor(value, fallback) {
|
|
228
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
229
|
+
const eight = /^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})$/.exec(text)
|
|
230
|
+
if (eight !== null) return { color: '#' + eight[1], opacity: Math.round((parseInt(eight[2], 16) / 255) * 1000) / 1000 }
|
|
231
|
+
return { color: text.length > 0 ? text : fallback, opacity: 1 }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function styleOf(node) {
|
|
235
|
+
return typeof node.style === 'string' ? node.style : ''
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function shapeOf(node) {
|
|
239
|
+
return nodeShapeFromStyle(styleOf(node))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function colorOf(node, palette) {
|
|
243
|
+
const style = styleOf(node)
|
|
244
|
+
const fill = styleGet(style, 'fillColor', null)
|
|
245
|
+
const stroke = styleGet(style, 'strokeColor', null)
|
|
246
|
+
const font = styleGet(style, 'fontColor', null)
|
|
247
|
+
return {
|
|
248
|
+
fill: fill !== null ? fill : palette.default.fill,
|
|
249
|
+
stroke: stroke !== null ? stroke : palette.default.stroke,
|
|
250
|
+
font: font !== null ? font : defaultFontOnFill(fill !== null ? fill : palette.default.fill),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── 标签排版(src/client.js:1031-1307)───────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function labelWraps(node) {
|
|
257
|
+
return styleGet(styleOf(node), 'whiteSpace', 'wrap') !== 'nowrap'
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function labelInsetsOf(node) {
|
|
261
|
+
const style = styleOf(node)
|
|
262
|
+
const shape = shapeOf(node)
|
|
263
|
+
const section = shape === 'section'
|
|
264
|
+
const base = styleNumber(style, 'spacing', null)
|
|
265
|
+
const lr = base !== null ? base : 6
|
|
266
|
+
const tb = base !== null ? base : 0
|
|
267
|
+
return {
|
|
268
|
+
left: styleNumber(style, 'spacingLeft', section ? 10 : lr),
|
|
269
|
+
right: styleNumber(style, 'spacingRight', lr),
|
|
270
|
+
top: styleNumber(style, 'spacingTop', section ? 6 : tb),
|
|
271
|
+
bottom: styleNumber(style, 'spacingBottom', tb),
|
|
272
|
+
align: styleGet(style, 'align', section || shape === 'swimlane' ? 'left' : 'center'),
|
|
273
|
+
verticalAlign: styleGet(style, 'verticalAlign', section ? 'top' : 'middle'),
|
|
274
|
+
labelPosition: styleGet(style, 'labelPosition', 'center'),
|
|
275
|
+
verticalLabelPosition: styleGet(style, 'verticalLabelPosition', 'middle'),
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function swimlaneStartSize(node) {
|
|
280
|
+
const n = styleNumber(styleOf(node), 'startSize', 23)
|
|
281
|
+
return n > 0 ? n : 23
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function labelTextWidth(node, w) {
|
|
285
|
+
const insets = labelInsetsOf(node)
|
|
286
|
+
const shape = shapeOf(node)
|
|
287
|
+
const inner = Math.max(12, w - insets.left - insets.right)
|
|
288
|
+
if (shape === 'diamond') return Math.max(12, inner * 0.6)
|
|
289
|
+
if (shape === 'ellipse') return Math.max(12, inner * 0.72)
|
|
290
|
+
return inner
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function imageContentRect(node, geo) {
|
|
294
|
+
const label = typeof node.label === 'string' ? node.label : ''
|
|
295
|
+
const insets = labelInsetsOf(node)
|
|
296
|
+
const pos = insets.labelPosition
|
|
297
|
+
const vpos = insets.verticalLabelPosition
|
|
298
|
+
if (label.length === 0 || (pos === 'center' && vpos === 'middle')) {
|
|
299
|
+
return { x: geo.x, y: geo.y, w: geo.w, h: geo.h }
|
|
300
|
+
}
|
|
301
|
+
if (pos === 'center' && (vpos === 'top' || vpos === 'bottom')) {
|
|
302
|
+
const band = Math.max(labelLineHeight(FSIZE), Math.min(geo.h * 0.35, 28))
|
|
303
|
+
if (band > 0) {
|
|
304
|
+
return { x: geo.x, y: vpos === 'top' ? geo.y + band : geo.y, w: geo.w, h: Math.max(1, geo.h - band) }
|
|
305
|
+
}
|
|
306
|
+
return { x: geo.x, y: geo.y, w: geo.w, h: geo.h }
|
|
307
|
+
}
|
|
308
|
+
if (pos === 'right' || pos === 'left') {
|
|
309
|
+
const split = Math.max(Math.min(geo.h, geo.w * 0.45), Math.min(geo.w * 0.5, 28))
|
|
310
|
+
if (pos === 'right') return { x: geo.x, y: geo.y, w: split, h: geo.h }
|
|
311
|
+
return { x: geo.x + geo.w - split, y: geo.y, w: split, h: geo.h }
|
|
312
|
+
}
|
|
313
|
+
if (pos === 'bottom' || pos === 'top') {
|
|
314
|
+
const split = Math.max(Math.min(geo.w, geo.h * 0.45), Math.min(geo.h * 0.5, 28))
|
|
315
|
+
if (pos === 'bottom') return { x: geo.x, y: geo.y, w: geo.w, h: split }
|
|
316
|
+
return { x: geo.x, y: geo.y + geo.h - split, w: geo.w, h: split }
|
|
317
|
+
}
|
|
318
|
+
return { x: geo.x, y: geo.y, w: geo.w, h: geo.h }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function labelTextBox(box) {
|
|
322
|
+
const geo = box.geo
|
|
323
|
+
const node = box.node
|
|
324
|
+
const style = styleOf(node)
|
|
325
|
+
const insets = labelInsetsOf(node)
|
|
326
|
+
const fontSize = numberOr(box.fontSize, styleNumber(style, 'fontSize', FSIZE))
|
|
327
|
+
const lineHeight = labelLineHeight(fontSize)
|
|
328
|
+
const text = typeof box.text === 'string' ? box.text : typeof node.label === 'string' ? node.label : ''
|
|
329
|
+
const shape = shapeOf(node)
|
|
330
|
+
|
|
331
|
+
let areaX = geo.x
|
|
332
|
+
let areaY = geo.y
|
|
333
|
+
let areaW = geo.w
|
|
334
|
+
let areaH = geo.h
|
|
335
|
+
if (shape === 'swimlane') {
|
|
336
|
+
areaH = Math.min(geo.h, swimlaneStartSize(node))
|
|
337
|
+
} else if ((shape === 'image' || shape === 'svg') && text.length > 0) {
|
|
338
|
+
const pos = insets.labelPosition
|
|
339
|
+
const vpos = insets.verticalLabelPosition
|
|
340
|
+
if (pos === 'right' || pos === 'left') {
|
|
341
|
+
const img = imageContentRect(node, geo)
|
|
342
|
+
if (pos === 'right') {
|
|
343
|
+
areaX = img.x + img.w
|
|
344
|
+
areaW = Math.max(12, geo.w - img.w)
|
|
345
|
+
} else {
|
|
346
|
+
areaW = Math.max(12, geo.w - img.w)
|
|
347
|
+
}
|
|
348
|
+
} else if (pos === 'bottom' || pos === 'top') {
|
|
349
|
+
const img = imageContentRect(node, geo)
|
|
350
|
+
if (pos === 'bottom') {
|
|
351
|
+
areaY = img.y + img.h
|
|
352
|
+
areaH = Math.max(lineHeight, geo.h - img.h)
|
|
353
|
+
} else {
|
|
354
|
+
areaH = Math.max(lineHeight, geo.h - img.h)
|
|
355
|
+
}
|
|
356
|
+
} else if (vpos === 'top' || vpos === 'bottom') {
|
|
357
|
+
const band = Math.max(lineHeight + insets.top + insets.bottom, Math.min(geo.h * 0.35, 28))
|
|
358
|
+
if (vpos === 'top') areaH = band
|
|
359
|
+
else {
|
|
360
|
+
areaY = geo.y + geo.h - band
|
|
361
|
+
areaH = band
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const w = Math.max(12, labelTextWidth(Object.assign({}, node, { w: areaW, h: areaH }), areaW))
|
|
367
|
+
const cols = labelWraps(node) ? wrapLabel(text, w, fontSize).length : String(text).split('\n').length
|
|
368
|
+
const h = Math.max(lineHeight * cols, lineHeight)
|
|
369
|
+
|
|
370
|
+
const innerX = areaX + insets.left
|
|
371
|
+
const innerRight = areaX + areaW - insets.right
|
|
372
|
+
const innerY = areaY + insets.top
|
|
373
|
+
const innerBottom = areaY + areaH - insets.bottom
|
|
374
|
+
let x
|
|
375
|
+
if (insets.align === 'left') x = innerX
|
|
376
|
+
else if (insets.align === 'right') x = innerRight - w
|
|
377
|
+
else x = (innerX + innerRight - w) / 2
|
|
378
|
+
let y
|
|
379
|
+
if (insets.verticalAlign === 'top') y = innerY
|
|
380
|
+
else if (insets.verticalAlign === 'bottom') y = innerBottom - h
|
|
381
|
+
else y = (innerY + innerBottom - h) / 2
|
|
382
|
+
|
|
383
|
+
return { x: x, y: y, w: w, h: h, fontSize: fontSize, lineHeight: lineHeight, align: insets.align, verticalAlign: insets.verticalAlign }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 标签文字实际占的框(换行后最宽一行 + 行数),用于导出包围盒。 */
|
|
387
|
+
function labelPaintBounds(node, geo) {
|
|
388
|
+
const label = typeof node.label === 'string' ? node.label : ''
|
|
389
|
+
if (label.length === 0) return null
|
|
390
|
+
if (isVideoNode(node)) return null
|
|
391
|
+
const style = styleOf(node)
|
|
392
|
+
const fontSize = styleNumber(style, 'fontSize', FSIZE)
|
|
393
|
+
const lineHeight = labelLineHeight(fontSize)
|
|
394
|
+
const box = labelTextBox({ node: node, geo: geo, fontSize: fontSize })
|
|
395
|
+
const lines = labelWraps(node) ? wrapLabel(label, box.w, fontSize) : label.split('\n')
|
|
396
|
+
let widest = 0
|
|
397
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
398
|
+
const w = textWidth(lines[i], fontSize)
|
|
399
|
+
if (w > widest) widest = w
|
|
400
|
+
}
|
|
401
|
+
const align = box.align === 'left' || box.align === 'right' ? box.align : 'center'
|
|
402
|
+
const cx = align === 'left' ? box.x : align === 'right' ? box.x + box.w : box.x + box.w / 2
|
|
403
|
+
const first = box.y + lineHeight / 2
|
|
404
|
+
return {
|
|
405
|
+
minX: align === 'left' ? cx : align === 'right' ? cx - widest : cx - widest / 2,
|
|
406
|
+
maxX: align === 'left' ? cx + widest : align === 'right' ? cx : cx + widest / 2,
|
|
407
|
+
minY: first - lineHeight / 2,
|
|
408
|
+
maxY: first + (lines.length - 1) * lineHeight + lineHeight / 2,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 节点"装得下自己的文字"需要多高(src/client.js:1272)。
|
|
414
|
+
*
|
|
415
|
+
* 与客户端同一个口径,是为了让导出里被文字撑高的节点**也**按撑高后的高度画,
|
|
416
|
+
* 否则多行标签的下半截会落到框外。
|
|
417
|
+
*/
|
|
418
|
+
function nodeTextFitHeight(node) {
|
|
419
|
+
if (!labelWraps(node)) return 0
|
|
420
|
+
const shape = shapeOf(node)
|
|
421
|
+
if (shape === 'image') return 0
|
|
422
|
+
if (shape === 'text' || shape === 'section' || shape === 'swimlane' || shape === 'svg' || shape === 'video') return 0
|
|
423
|
+
const w = numberOr(node.w, FALLBACK_NODE_W)
|
|
424
|
+
const label = typeof node.label === 'string' ? node.label : ''
|
|
425
|
+
if (label.length === 0) return 0
|
|
426
|
+
const fontSize = styleNumber(styleOf(node), 'fontSize', FSIZE)
|
|
427
|
+
const lines = wrapLabel(label, labelTextWidth(node, w), fontSize).length
|
|
428
|
+
if (lines <= 1) return 0
|
|
429
|
+
return (lines + 1) * labelLineHeight(fontSize)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function nodeBoxOf(node) {
|
|
433
|
+
const w = numberOr(node.w, FALLBACK_NODE_W)
|
|
434
|
+
const stored = numberOr(node.h, FALLBACK_NODE_H)
|
|
435
|
+
const need = nodeTextFitHeight(node)
|
|
436
|
+
if (need > stored) return { x: numberOr(node.x, 0), y: numberOr(node.y, 0), w: w, h: snapTo(need, GRID) }
|
|
437
|
+
return { x: numberOr(node.x, 0), y: numberOr(node.y, 0), w: w, h: stored }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 视频节点由客户端组件画(帧序列/播放器),导出只给通用形状 —— 也不吃标签。 */
|
|
441
|
+
function isVideoNode(node) {
|
|
442
|
+
const data = node !== null && typeof node === 'object' ? node.data : null
|
|
443
|
+
return data !== null && typeof data === 'object' && (typeof data.videoClip === 'string' || typeof data.motion === 'object')
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── 路径序列化(src/client.js:2469-2507)───────────────────────────────
|
|
447
|
+
|
|
448
|
+
function dedupePoints(points) {
|
|
449
|
+
const out = []
|
|
450
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
451
|
+
const p = points[i]
|
|
452
|
+
if (out.length > 0) {
|
|
453
|
+
const prev = out[out.length - 1]
|
|
454
|
+
if (Math.abs(prev.x - p.x) < 0.01 && Math.abs(prev.y - p.y) < 0.01) continue
|
|
455
|
+
}
|
|
456
|
+
out.push({ x: p.x, y: p.y })
|
|
457
|
+
}
|
|
458
|
+
return out
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function pathOf(rawPoints, radius, curved) {
|
|
462
|
+
const pts = dedupePoints(rawPoints)
|
|
463
|
+
if (pts.length < 2) return ''
|
|
464
|
+
const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 })
|
|
465
|
+
if (curved === true) {
|
|
466
|
+
if (pts.length === 2) return 'M ' + fmt(pts[0].x) + ' ' + fmt(pts[0].y) + ' L ' + fmt(pts[1].x) + ' ' + fmt(pts[1].y)
|
|
467
|
+
let d = 'M ' + fmt(pts[0].x) + ' ' + fmt(pts[0].y)
|
|
468
|
+
for (let i = 1; i < pts.length - 1; i += 1) {
|
|
469
|
+
const end = i === pts.length - 2 ? pts[pts.length - 1] : mid(pts[i], pts[i + 1])
|
|
470
|
+
d += ' Q ' + fmt(pts[i].x) + ' ' + fmt(pts[i].y) + ' ' + fmt(end.x) + ' ' + fmt(end.y)
|
|
471
|
+
}
|
|
472
|
+
return d
|
|
473
|
+
}
|
|
474
|
+
const r = Math.max(0, numberOr(radius, 0))
|
|
475
|
+
let d = 'M ' + fmt(pts[0].x) + ' ' + fmt(pts[0].y)
|
|
476
|
+
for (let i = 1; i < pts.length - 1; i += 1) {
|
|
477
|
+
const prev = pts[i - 1]
|
|
478
|
+
const cur = pts[i]
|
|
479
|
+
const next = pts[i + 1]
|
|
480
|
+
if (r <= 0) {
|
|
481
|
+
d += ' L ' + fmt(cur.x) + ' ' + fmt(cur.y)
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
const d1 = Math.abs(cur.x - prev.x) + Math.abs(cur.y - prev.y)
|
|
485
|
+
const d2 = Math.abs(next.x - cur.x) + Math.abs(next.y - cur.y)
|
|
486
|
+
const rr = Math.min(r, d1 / 2, d2 / 2)
|
|
487
|
+
if (rr <= 0.01) {
|
|
488
|
+
d += ' L ' + fmt(cur.x) + ' ' + fmt(cur.y)
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
const len1 = Math.hypot(cur.x - prev.x, cur.y - prev.y) || 1
|
|
492
|
+
const len2 = Math.hypot(next.x - cur.x, next.y - cur.y) || 1
|
|
493
|
+
const a = { x: cur.x - ((cur.x - prev.x) / len1) * rr, y: cur.y - ((cur.y - prev.y) / len1) * rr }
|
|
494
|
+
const b = { x: cur.x + ((next.x - cur.x) / len2) * rr, y: cur.y + ((next.y - cur.y) / len2) * rr }
|
|
495
|
+
d += ' L ' + fmt(a.x) + ' ' + fmt(a.y) + ' Q ' + fmt(cur.x) + ' ' + fmt(cur.y) + ' ' + fmt(b.x) + ' ' + fmt(b.y)
|
|
496
|
+
}
|
|
497
|
+
const last = pts[pts.length - 1]
|
|
498
|
+
d += ' L ' + fmt(last.x) + ' ' + fmt(last.y)
|
|
499
|
+
return d
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── 连线上文字的落点与"给字让路"(src/client.js:5924-6196)──────────────
|
|
503
|
+
|
|
504
|
+
function arcLengths(pts) {
|
|
505
|
+
const segs = []
|
|
506
|
+
let total = 0
|
|
507
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
508
|
+
const len = Math.abs(pts[i].x - pts[i - 1].x) + Math.abs(pts[i].y - pts[i - 1].y)
|
|
509
|
+
segs.push(len)
|
|
510
|
+
total += len
|
|
511
|
+
}
|
|
512
|
+
return { segs: segs, total: total }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function pointAtArc(pts, segs, d) {
|
|
516
|
+
let acc = 0
|
|
517
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
518
|
+
const seg = segs[i]
|
|
519
|
+
if (d <= acc + seg || i === segs.length - 1) {
|
|
520
|
+
const t = seg === 0 ? 0 : Math.max(0, Math.min(1, (d - acc) / seg))
|
|
521
|
+
return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t }
|
|
522
|
+
}
|
|
523
|
+
acc += seg
|
|
524
|
+
}
|
|
525
|
+
return { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** drawio 的边标签落点:沿弧长的中点,拖过就按 labelX/labelY 的相对量。 */
|
|
529
|
+
function edgeLabelPointAt(pts, x, y, offsetX, offsetY) {
|
|
530
|
+
if (Array.isArray(pts) === false || pts.length < 2) return null
|
|
531
|
+
const segs = []
|
|
532
|
+
let total = 0
|
|
533
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
534
|
+
const len = Math.abs(pts[i].x - pts[i - 1].x) + Math.abs(pts[i].y - pts[i - 1].y)
|
|
535
|
+
segs.push(len)
|
|
536
|
+
total += len
|
|
537
|
+
}
|
|
538
|
+
if (total <= 0) return null
|
|
539
|
+
const gx = (Number.isFinite(Number(x)) ? Number(x) : 0) / 2
|
|
540
|
+
let dist = Math.round((gx + 0.5) * total)
|
|
541
|
+
if (dist < 0) dist = 0
|
|
542
|
+
if (dist > total) dist = total
|
|
543
|
+
let acc = 0
|
|
544
|
+
let index = 0
|
|
545
|
+
while (index < segs.length - 1 && dist >= Math.round(acc + segs[index])) {
|
|
546
|
+
acc += segs[index]
|
|
547
|
+
index += 1
|
|
548
|
+
}
|
|
549
|
+
const seg = segs[index]
|
|
550
|
+
const factor = seg === 0 ? 0 : (dist - acc) / seg
|
|
551
|
+
const p0 = pts[index]
|
|
552
|
+
const pe = pts[index + 1]
|
|
553
|
+
if (p0 === undefined || pe === undefined) return null
|
|
554
|
+
const dx = pe.x - p0.x
|
|
555
|
+
const dy = pe.y - p0.y
|
|
556
|
+
const nx = seg === 0 ? 0 : dy / seg
|
|
557
|
+
const ny = seg === 0 ? 0 : dx / seg
|
|
558
|
+
const perp = Number.isFinite(Number(y)) ? Number(y) : 0
|
|
559
|
+
const ox = Number.isFinite(Number(offsetX)) ? Number(offsetX) : 0
|
|
560
|
+
const oy = Number.isFinite(Number(offsetY)) ? Number(offsetY) : 0
|
|
561
|
+
return { x: p0.x + dx * factor + (nx * perp + ox), y: p0.y + dy * factor - (ny * perp - oy) }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function labelBox(pos, text, fontSize) {
|
|
565
|
+
if (pos === null || pos === undefined) return null
|
|
566
|
+
const lines = String(text || '').split('\n')
|
|
567
|
+
let widest = 0
|
|
568
|
+
for (let i = 0; i < lines.length; i += 1) widest = Math.max(widest, textWidth(lines[i], fontSize))
|
|
569
|
+
const w = widest + 6
|
|
570
|
+
const h = labelLineHeight(fontSize) * lines.length + 2
|
|
571
|
+
return { x: pos.x - w / 2, y: pos.y + 1 - h / 2, w: w, h: h }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const growBox = (box, pad) => ({ x: box.x - pad, y: box.y - pad, w: box.w + pad * 2, h: box.h + pad * 2 })
|
|
575
|
+
|
|
576
|
+
/** 标签压在线上时,线要在它这块区域里断开 —— drawio 的观感:字的位置不画线。 */
|
|
577
|
+
function labelGapsOnPath(pts, box) {
|
|
578
|
+
if (Array.isArray(pts) === false || pts.length < 2) return []
|
|
579
|
+
if (box === null || box === undefined) return []
|
|
580
|
+
const segs = arcLengths(pts).segs
|
|
581
|
+
const gaps = []
|
|
582
|
+
let acc = 0
|
|
583
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
584
|
+
const a = pts[i]
|
|
585
|
+
const b = pts[i + 1]
|
|
586
|
+
const seg = segs[i]
|
|
587
|
+
if (seg > 0) {
|
|
588
|
+
if (Math.abs(a.y - b.y) < 0.5) {
|
|
589
|
+
if (a.y >= box.y && a.y <= box.y + box.h) {
|
|
590
|
+
const from = Math.max(Math.min(a.x, b.x), box.x)
|
|
591
|
+
const to = Math.min(Math.max(a.x, b.x), box.x + box.w)
|
|
592
|
+
if (to > from) {
|
|
593
|
+
const s0 = a.x <= b.x ? acc + (from - a.x) : acc + (a.x - from)
|
|
594
|
+
const s1 = a.x <= b.x ? acc + (to - a.x) : acc + (a.x - to)
|
|
595
|
+
gaps.push({ from: Math.min(s0, s1), to: Math.max(s0, s1) })
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} else if (Math.abs(a.x - b.x) < 0.5) {
|
|
599
|
+
if (a.x >= box.x && a.x <= box.x + box.w) {
|
|
600
|
+
const from = Math.max(Math.min(a.y, b.y), box.y)
|
|
601
|
+
const to = Math.min(Math.max(a.y, b.y), box.y + box.h)
|
|
602
|
+
if (to > from) {
|
|
603
|
+
const s0 = a.y <= b.y ? acc + (from - a.y) : acc + (a.y - from)
|
|
604
|
+
const s1 = a.y <= b.y ? acc + (to - a.y) : acc + (a.y - to)
|
|
605
|
+
gaps.push({ from: Math.min(s0, s1), to: Math.max(s0, s1) })
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
acc += seg
|
|
611
|
+
}
|
|
612
|
+
gaps.sort((x, y) => x.from - y.from)
|
|
613
|
+
const merged = []
|
|
614
|
+
for (let i = 0; i < gaps.length; i += 1) {
|
|
615
|
+
const last = merged[merged.length - 1]
|
|
616
|
+
if (last !== undefined && gaps[i].from <= last.to) last.to = Math.max(last.to, gaps[i].to)
|
|
617
|
+
else merged.push({ from: gaps[i].from, to: gaps[i].to })
|
|
618
|
+
}
|
|
619
|
+
return merged
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 按弧长区间把折线挖空;两端各留一小截,免得长标签把整条线(连箭头)吃掉。 */
|
|
623
|
+
function cutPathByGaps(pts, gaps) {
|
|
624
|
+
if (Array.isArray(gaps) === false || gaps.length === 0) return [pts]
|
|
625
|
+
const lengths = arcLengths(pts)
|
|
626
|
+
const segs = lengths.segs
|
|
627
|
+
const total = lengths.total
|
|
628
|
+
if (total <= 0) return [pts]
|
|
629
|
+
const keep = Math.min(8, total * 0.2)
|
|
630
|
+
const kept = []
|
|
631
|
+
let cursor = 0
|
|
632
|
+
for (let i = 0; i < gaps.length; i += 1) {
|
|
633
|
+
const from = Math.max(keep, gaps[i].from)
|
|
634
|
+
const to = Math.min(total - keep, gaps[i].to)
|
|
635
|
+
if (to <= from) continue
|
|
636
|
+
if (from > cursor) kept.push([cursor, from])
|
|
637
|
+
cursor = Math.max(cursor, to)
|
|
638
|
+
}
|
|
639
|
+
if (cursor < total) kept.push([cursor, total])
|
|
640
|
+
const out = []
|
|
641
|
+
for (let k = 0; k < kept.length; k += 1) {
|
|
642
|
+
const a = kept[k][0]
|
|
643
|
+
const b = kept[k][1]
|
|
644
|
+
if (b - a <= 0.01) continue
|
|
645
|
+
const sub = [pointAtArc(pts, segs, a)]
|
|
646
|
+
let acc = 0
|
|
647
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
648
|
+
acc += segs[i]
|
|
649
|
+
if (acc > a + 0.01 && acc < b - 0.01) sub.push({ x: pts[i + 1].x, y: pts[i + 1].y })
|
|
650
|
+
}
|
|
651
|
+
sub.push(pointAtArc(pts, segs, b))
|
|
652
|
+
out.push(sub)
|
|
653
|
+
}
|
|
654
|
+
return out.length > 0 ? out : [pts]
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ── 形状(src/client.js:2509-2731)──────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
function shapeSvg(node, geo, palette, warnings) {
|
|
660
|
+
const style = styleOf(node)
|
|
661
|
+
const shape = shapeOf(node)
|
|
662
|
+
const colors = colorOf(node, palette)
|
|
663
|
+
const common = {
|
|
664
|
+
fill: colors.fill,
|
|
665
|
+
stroke: colors.stroke,
|
|
666
|
+
'stroke-width': fmt(styleNumber(style, 'strokeWidth', 1)),
|
|
667
|
+
}
|
|
668
|
+
const fillOpacity = Math.max(0, Math.min(100, styleNumber(style, 'fillOpacity', 100))) / 100
|
|
669
|
+
const strokeOpacity = Math.max(0, Math.min(100, styleNumber(style, 'strokeOpacity', 100))) / 100
|
|
670
|
+
if (fillOpacity < 1) common['fill-opacity'] = fmt(fillOpacity)
|
|
671
|
+
if (strokeOpacity < 1) common['stroke-opacity'] = fmt(strokeOpacity)
|
|
672
|
+
const dash = dashPatternFromStyle(style)
|
|
673
|
+
if (dash !== null) common['stroke-dasharray'] = dash
|
|
674
|
+
|
|
675
|
+
const x = geo.x
|
|
676
|
+
const y = geo.y
|
|
677
|
+
const w = geo.w
|
|
678
|
+
const h = geo.h
|
|
679
|
+
|
|
680
|
+
if (shape === 'text') {
|
|
681
|
+
return ''
|
|
682
|
+
}
|
|
683
|
+
if (shape === 'section') {
|
|
684
|
+
const section = Object.assign({}, common, { x: fmt(x), y: fmt(y), width: fmt(w), height: fmt(h), rx: 4, ry: 4 })
|
|
685
|
+
if (styleGet(style, 'fillColor', null) === null) section.fill = '#f5f5f5'
|
|
686
|
+
if (dash === null) section['stroke-dasharray'] = '6 3'
|
|
687
|
+
return '<rect' + attrs(section) + '/>'
|
|
688
|
+
}
|
|
689
|
+
if (shape === 'swimlane') {
|
|
690
|
+
const startSize = swimlaneStartSize(node)
|
|
691
|
+
const header = Math.min(h, startSize)
|
|
692
|
+
const body = Object.assign({}, common, { x: fmt(x), y: fmt(y), width: fmt(w), height: fmt(h) })
|
|
693
|
+
if (styleGet(style, 'fillColor', null) === null) body.fill = '#ffffff'
|
|
694
|
+
const headFill = styleGet(style, 'swimlaneFillColor', null)
|
|
695
|
+
const parts = ['<rect' + attrs(body) + '/>']
|
|
696
|
+
parts.push('<rect' + attrs({
|
|
697
|
+
x: fmt(x), y: fmt(y), width: fmt(w), height: fmt(header),
|
|
698
|
+
fill: headFill !== null ? headFill : body.fill, stroke: 'none',
|
|
699
|
+
}) + '/>')
|
|
700
|
+
parts.push('<line' + attrs({
|
|
701
|
+
x1: fmt(x), y1: fmt(y + header), x2: fmt(x + w), y2: fmt(y + header),
|
|
702
|
+
stroke: colors.stroke, 'stroke-width': fmt(styleNumber(style, 'strokeWidth', 1)),
|
|
703
|
+
}) + '/>')
|
|
704
|
+
return parts.join('')
|
|
705
|
+
}
|
|
706
|
+
if (shape === 'svg' || shape === 'image') {
|
|
707
|
+
const href = imageValueFromStyle(style)
|
|
708
|
+
const rect = imageContentRect(node, geo)
|
|
709
|
+
const usable = typeof href === 'string' && /^data:image\//i.test(href)
|
|
710
|
+
if (usable === false) {
|
|
711
|
+
warnings.push('图片/SVG 节点 "' + String(node.id) + '" 的 image= 不可解析,导出为虚线占位框')
|
|
712
|
+
return '<rect' + attrs({
|
|
713
|
+
x: fmt(rect.x + 0.5), y: fmt(rect.y + 0.5), width: fmt(Math.max(1, rect.w - 1)), height: fmt(Math.max(1, rect.h - 1)),
|
|
714
|
+
fill: 'none', stroke: colors.stroke, 'stroke-width': 1, 'stroke-dasharray': '3 3',
|
|
715
|
+
}) + '/>'
|
|
716
|
+
}
|
|
717
|
+
// imageAspect=1 等比居中、0/缺省拉伸铺满(drawio 语义);位图缺省 1、svg 缺省 0。
|
|
718
|
+
const fit = styleGet(style, 'imageAspect', shape === 'image' ? '1' : '0') === '1'
|
|
719
|
+
return '<image' + attrs({
|
|
720
|
+
x: fmt(rect.x), y: fmt(rect.y), width: fmt(rect.w), height: fmt(rect.h),
|
|
721
|
+
href: href, 'preserveAspectRatio': fit ? 'xMidYMid meet' : 'none',
|
|
722
|
+
}) + '/>'
|
|
723
|
+
}
|
|
724
|
+
if (shape === 'ellipse') {
|
|
725
|
+
return '<ellipse' + attrs(Object.assign({}, common, {
|
|
726
|
+
cx: fmt(x + w / 2), cy: fmt(y + h / 2), rx: fmt(w / 2), ry: fmt(h / 2),
|
|
727
|
+
})) + '/>'
|
|
728
|
+
}
|
|
729
|
+
if (shape === 'diamond') {
|
|
730
|
+
return '<polygon' + attrs(Object.assign({}, common, {
|
|
731
|
+
points: [x + w / 2, y, x + w, y + h / 2, x + w / 2, y + h, x, y + h / 2].map(fmt).join(' '),
|
|
732
|
+
})) + '/>'
|
|
733
|
+
}
|
|
734
|
+
if (shape === 'parallelogram') {
|
|
735
|
+
const s = Math.min(w * 0.2, 24)
|
|
736
|
+
return '<polygon' + attrs(Object.assign({}, common, {
|
|
737
|
+
points: [x + s, y, x + w, y, x + w - s, y + h, x, y + h].map(fmt).join(' '),
|
|
738
|
+
})) + '/>'
|
|
739
|
+
}
|
|
740
|
+
if (shape === 'hexagon') {
|
|
741
|
+
const s = Math.min(w * 0.15, 20)
|
|
742
|
+
return '<polygon' + attrs(Object.assign({}, common, {
|
|
743
|
+
points: [x + s, y, x + w - s, y, x + w, y + h / 2, x + w - s, y + h, x + s, y + h, x, y + h / 2].map(fmt).join(' '),
|
|
744
|
+
})) + '/>'
|
|
745
|
+
}
|
|
746
|
+
if (shape === 'cylinder') {
|
|
747
|
+
const ry = Math.min(h * 0.18, 14)
|
|
748
|
+
const body = ['M', fmt(x), fmt(y + ry), 'A', fmt(w / 2), fmt(ry), '0', '0', '1', fmt(x + w), fmt(y + ry),
|
|
749
|
+
'L', fmt(x + w), fmt(y + h - ry), 'A', fmt(w / 2), fmt(ry), '0', '0', '1', fmt(x), fmt(y + h - ry), 'Z'].join(' ')
|
|
750
|
+
const lid = ['M', fmt(x), fmt(y + ry), 'A', fmt(w / 2), fmt(ry), '0', '0', '0', fmt(x + w), fmt(y + ry)].join(' ')
|
|
751
|
+
return '<path' + attrs(Object.assign({}, common, { d: body })) + '/>' +
|
|
752
|
+
'<path' + attrs(Object.assign({}, common, { d: lid, fill: 'none' })) + '/>'
|
|
753
|
+
}
|
|
754
|
+
if (shape === 'document') {
|
|
755
|
+
const d = ['M', fmt(x), fmt(y), 'L', fmt(x + w), fmt(y), 'L', fmt(x + w), fmt(y + h * 0.82),
|
|
756
|
+
'C', fmt(x + w * 0.75), fmt(y + h * 1.06), fmt(x + w * 0.25), fmt(y + h * 0.58), fmt(x), fmt(y + h * 0.86), 'Z'].join(' ')
|
|
757
|
+
return '<path' + attrs(Object.assign({}, common, { d: d })) + '/>'
|
|
758
|
+
}
|
|
759
|
+
// 其余(rect / rounded / stadium):drawio 的圆角是 rounded=1 + arcSize(百分比,缺省 15%)。
|
|
760
|
+
let rx = 0
|
|
761
|
+
if (shape === 'rounded' || shape === 'stadium') {
|
|
762
|
+
const arc = styleNumber(style, 'arcSize', null)
|
|
763
|
+
rx = shape === 'stadium' ? h / 2 : Math.min(w, h) * (arc === null ? 0.15 : arc / 100)
|
|
764
|
+
}
|
|
765
|
+
const rect = Object.assign({}, common, { x: fmt(x), y: fmt(y), width: fmt(w), height: fmt(h) })
|
|
766
|
+
if (rx > 0) {
|
|
767
|
+
rect.rx = fmt(rx)
|
|
768
|
+
rect.ry = fmt(rx)
|
|
769
|
+
}
|
|
770
|
+
return '<rect' + attrs(rect) + '/>'
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ── 文字 ────────────────────────────────────────────────────────────────
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* 一行文字 → `<text>` + 每行一个 `<tspan>`。
|
|
777
|
+
*
|
|
778
|
+
* 基线偏移手算(`top + lineHeight/2 + 0.35em`)而不用 `dominant-baseline="middle"`:
|
|
779
|
+
* 浏览器、resvg、Inkscape 对那个属性各有各的解释,手算的版本在哪都一样。
|
|
780
|
+
*/
|
|
781
|
+
function textSvg(spec) {
|
|
782
|
+
const lines = Array.isArray(spec.lines) ? spec.lines : ['']
|
|
783
|
+
const fontSize = numberOr(spec.fontSize, FSIZE)
|
|
784
|
+
const lineHeight = numberOr(spec.lineHeight, labelLineHeight(fontSize))
|
|
785
|
+
const baseline = numberOr(spec.top, 0) + lineHeight / 2 + fontSize * 0.35
|
|
786
|
+
const spans = lines.map((line, i) => '<tspan' + attrs({ x: fmt(spec.x), dy: i === 0 ? 0 : fmt(lineHeight) }) + '>' +
|
|
787
|
+
escapeXml(line) + '</tspan>').join('')
|
|
788
|
+
const opacity = numberOr(spec.opacity, 1)
|
|
789
|
+
return '<text' + attrs({
|
|
790
|
+
x: fmt(spec.x),
|
|
791
|
+
y: fmt(baseline),
|
|
792
|
+
'text-anchor': spec.anchor === 'start' || spec.anchor === 'end' ? spec.anchor : 'middle',
|
|
793
|
+
'font-family': spec.family || SVG_FONT,
|
|
794
|
+
'font-size': fmt(fontSize),
|
|
795
|
+
fill: spec.fill,
|
|
796
|
+
'fill-opacity': opacity < 1 ? fmt(opacity) : null,
|
|
797
|
+
'xml:space': 'preserve',
|
|
798
|
+
}) + '>' + spans + '</text>'
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function nodeLabelSvg(node, geo, palette) {
|
|
802
|
+
const label = typeof node.label === 'string' ? node.label : ''
|
|
803
|
+
if (label.length === 0) return ''
|
|
804
|
+
if (isVideoNode(node)) return ''
|
|
805
|
+
const style = styleOf(node)
|
|
806
|
+
const shape = shapeOf(node)
|
|
807
|
+
if (shape === 'text') {
|
|
808
|
+
// 独立文字:没有框,直接按标签盒居中画(与客户端同一套 labelTextBox)。
|
|
809
|
+
const box = labelTextBox({ node: node, geo: geo, fontSize: styleNumber(style, 'fontSize', FSIZE) })
|
|
810
|
+
const lines = labelWraps(node) ? wrapLabel(label, box.w, box.fontSize) : label.split('\n')
|
|
811
|
+
const align = box.align === 'left' || box.align === 'right' ? box.align : 'center'
|
|
812
|
+
const x = align === 'left' ? box.x : align === 'right' ? box.x + box.w : box.x + box.w / 2
|
|
813
|
+
return textSvg({
|
|
814
|
+
x: x, top: box.y, lines: lines, fontSize: box.fontSize, lineHeight: box.lineHeight,
|
|
815
|
+
anchor: align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle',
|
|
816
|
+
fill: colorOf(node, palette).font,
|
|
817
|
+
opacity: Math.max(0, Math.min(100, styleNumber(style, 'textOpacity', 100))) / 100,
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
const fontSize = styleNumber(style, 'fontSize', FSIZE)
|
|
821
|
+
const box = labelTextBox({ node: node, geo: geo, fontSize: fontSize })
|
|
822
|
+
const lines = labelWraps(node) ? wrapLabel(label, box.w, fontSize) : String(label).split('\n')
|
|
823
|
+
const align = box.align === 'left' || box.align === 'right' ? box.align : 'center'
|
|
824
|
+
const x = align === 'left' ? box.x : align === 'right' ? box.x + box.w : box.x + box.w / 2
|
|
825
|
+
return textSvg({
|
|
826
|
+
x: x, top: box.y, lines: lines, fontSize: fontSize, lineHeight: box.lineHeight,
|
|
827
|
+
anchor: align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle',
|
|
828
|
+
fill: colorOf(node, palette).font,
|
|
829
|
+
opacity: Math.max(0, Math.min(100, styleNumber(style, 'textOpacity', 100))) / 100,
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ── 连线 ────────────────────────────────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
function hiddenLayerIds(doc) {
|
|
836
|
+
const set = new Set()
|
|
837
|
+
const layers = Array.isArray(doc.layers) ? doc.layers : []
|
|
838
|
+
for (let i = 0; i < layers.length; i += 1) {
|
|
839
|
+
const layer = layers[i]
|
|
840
|
+
if (layer !== null && typeof layer === 'object' && layer.visible === false) set.add(String(layer.id))
|
|
841
|
+
}
|
|
842
|
+
return set
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function isHiddenCell(hidden, cell) {
|
|
846
|
+
return cell.layer !== undefined && cell.layer !== null && hidden.has(String(cell.layer))
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** 节点几何:文档里的宽高 + 被文字撑高的那一份(客户端 nodeBoxOf 的宿主版)。 */
|
|
850
|
+
export function exportBoxes(doc) {
|
|
851
|
+
const hidden = hiddenLayerIds(doc)
|
|
852
|
+
const boxes = []
|
|
853
|
+
const nodes = Array.isArray(doc.nodes) ? doc.nodes : []
|
|
854
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
855
|
+
const node = nodes[i]
|
|
856
|
+
if (node === null || typeof node !== 'object') continue
|
|
857
|
+
if (isHiddenCell(hidden, node)) continue
|
|
858
|
+
boxes.push({ id: String(node.id), node: node, geo: nodeBoxOf(node) })
|
|
859
|
+
}
|
|
860
|
+
return boxes
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* 把一条带用户折点的连线**掰成直角**(客户端 `routeThroughWaypoints` 的简化版)。
|
|
865
|
+
*
|
|
866
|
+
* 为什么需要:`routeEdgeStyled` 对"手动/普通正交"的边不直接画存下来的折点,而是
|
|
867
|
+
* 逐个折点补拐角 —— 上一段横就先横后竖、上一段竖就先竖后横,最后一段垂直于落点所在的边。
|
|
868
|
+
* 导出若照抄折点,中间会多出斜线段(demo.drawio 里三条边都这样),与画布上看到的不是一个东西。
|
|
869
|
+
*
|
|
870
|
+
* 只处理"斜的"那一段:已经是横/竖的段原样保留,用户折点一个不丢、顺序也不变。
|
|
871
|
+
*/
|
|
872
|
+
function orthogonalizeRoute(points, style) {
|
|
873
|
+
if (Array.isArray(points) === false || points.length < 2) return points
|
|
874
|
+
const sourceSide = sideFromStyle(style, 'source')
|
|
875
|
+
const targetSide = sideFromStyle(style, 'target')
|
|
876
|
+
const sideIsHorizontal = (side) => side === 'e' || side === 'w'
|
|
877
|
+
const sideIsVertical = (side) => side === 'n' || side === 's'
|
|
878
|
+
const out = [{ x: points[0].x, y: points[0].y }]
|
|
879
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
880
|
+
const prev = out[out.length - 1]
|
|
881
|
+
const next = { x: points[i].x, y: points[i].y }
|
|
882
|
+
const dx = next.x - prev.x
|
|
883
|
+
const dy = next.y - prev.y
|
|
884
|
+
if (Math.abs(dx) < 0.01 || Math.abs(dy) < 0.01) {
|
|
885
|
+
out.push(next)
|
|
886
|
+
continue
|
|
887
|
+
}
|
|
888
|
+
// 拐弯方向跟着行进方向走:上一段是横的 → 先横后竖;是竖的 → 先竖后横。
|
|
889
|
+
let horizontalFirst
|
|
890
|
+
const last = out.length > 1 ? out[out.length - 2] : null
|
|
891
|
+
if (last !== null && Math.abs(prev.y - last.y) < 0.01) horizontalFirst = true
|
|
892
|
+
else if (last !== null && Math.abs(prev.x - last.x) < 0.01) horizontalFirst = false
|
|
893
|
+
else if (i === 1 && sideIsHorizontal(sourceSide)) horizontalFirst = true
|
|
894
|
+
else if (i === 1 && sideIsVertical(sourceSide)) horizontalFirst = false
|
|
895
|
+
else horizontalFirst = Math.abs(dx) >= Math.abs(dy)
|
|
896
|
+
// 最后一段必须垂直于落点所在的边:从左边进就横着进,从上面进就竖着进。
|
|
897
|
+
if (i === points.length - 1) {
|
|
898
|
+
if (sideIsHorizontal(targetSide)) horizontalFirst = false
|
|
899
|
+
else if (sideIsVertical(targetSide)) horizontalFirst = true
|
|
900
|
+
}
|
|
901
|
+
out.push(horizontalFirst ? { x: next.x, y: prev.y } : { x: prev.x, y: next.y })
|
|
902
|
+
out.push(next)
|
|
903
|
+
}
|
|
904
|
+
// 相邻重复点(折返时可能出现)去掉,否则 pathOf 的圆角会算出一段零长。
|
|
905
|
+
const dedup = [out[0]]
|
|
906
|
+
for (let i = 1; i < out.length; i += 1) {
|
|
907
|
+
const prev = dedup[dedup.length - 1]
|
|
908
|
+
if (Math.abs(out[i].x - prev.x) > 0.01 || Math.abs(out[i].y - prev.y) > 0.01) dedup.push(out[i])
|
|
909
|
+
}
|
|
910
|
+
return dedup
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const EDGE_SIDE_ORDER = ['e', 's', 'w', 'n']
|
|
914
|
+
|
|
915
|
+
function nextSideOf(side) {
|
|
916
|
+
const at = EDGE_SIDE_ORDER.indexOf(side)
|
|
917
|
+
return EDGE_SIDE_ORDER[(at < 0 ? 0 : at + 1) % EDGE_SIDE_ORDER.length]
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** 盒子某一侧中心落在边框上的点(自环的起止点,与客户端 sideBorderPoint:2191 同一口径)。 */
|
|
921
|
+
function sideCenterOf(geo, side) {
|
|
922
|
+
const cx = geo.x + geo.w / 2
|
|
923
|
+
const cy = geo.y + geo.h / 2
|
|
924
|
+
if (side === 'n') return { x: cx, y: geo.y }
|
|
925
|
+
if (side === 's') return { x: cx, y: geo.y + geo.h }
|
|
926
|
+
if (side === 'w') return { x: geo.x, y: cy }
|
|
927
|
+
return { x: geo.x + geo.w, y: cy }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** 从某一侧往外推 `inset` 的桩点:它决定首/末段所在的那条线(客户端 stubPointFor:1807)。 */
|
|
931
|
+
function stubPointOf(geo, side, inset) {
|
|
932
|
+
const cx = geo.x + geo.w / 2
|
|
933
|
+
const cy = geo.y + geo.h / 2
|
|
934
|
+
if (side === 'n') return { x: cx, y: geo.y - inset }
|
|
935
|
+
if (side === 's') return { x: cx, y: geo.y + geo.h + inset }
|
|
936
|
+
if (side === 'w') return { x: geo.x - inset, y: cy }
|
|
937
|
+
return { x: geo.x + geo.w + inset, y: cy }
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* 自环:`from === to` 的边,绕回同一个盒子(客户端 `selfLoopPath`,src/client.js:2218)。
|
|
942
|
+
*
|
|
943
|
+
* 为什么不能走 storedRoute:两端在同一个盒子上,裁出来的边框点是同一个点,
|
|
944
|
+
* 折线只剩一个点 —— 整条边被丢掉,还会顺手报一句"两端都落不到任何节点上",
|
|
945
|
+
* 而画布上它明明画得好好的。
|
|
946
|
+
*
|
|
947
|
+
* 形状规则与客户端一致:一侧横、一侧竖(e → s)时,唯一拐点取"横那侧的 x + 竖那侧的 y";
|
|
948
|
+
* 两侧相对(e → w 走盒子上下、n → s 走盒子左右)时绕出去一圈。用户摆过折点的按折点穿过去。
|
|
949
|
+
*/
|
|
950
|
+
function selfLoopRoute(geo, style, waypoints) {
|
|
951
|
+
const horizontalSide = (side) => side === 'e' || side === 'w'
|
|
952
|
+
const source = sideFromStyle(style, 'source') || 'e'
|
|
953
|
+
let target = sideFromStyle(style, 'target') || nextSideOf(source)
|
|
954
|
+
if (target === source) target = nextSideOf(source)
|
|
955
|
+
const jetty = styleNumber(style, 'jettySize', 0)
|
|
956
|
+
const inset = Number.isFinite(jetty) && jetty > 0 ? jetty : 35
|
|
957
|
+
const p0 = sideCenterOf(geo, source)
|
|
958
|
+
const p1 = sideCenterOf(geo, target)
|
|
959
|
+
const s = stubPointOf(geo, source, inset)
|
|
960
|
+
const t = stubPointOf(geo, target, inset)
|
|
961
|
+
const pts = [p0, s]
|
|
962
|
+
const chain = Array.isArray(waypoints) ? waypoints : []
|
|
963
|
+
if (chain.length > 0) {
|
|
964
|
+
for (let i = 0; i < chain.length; i += 1) {
|
|
965
|
+
const point = chain[i]
|
|
966
|
+
if (point === null || typeof point !== 'object') continue
|
|
967
|
+
const x = Number(point.x)
|
|
968
|
+
const y = Number(point.y)
|
|
969
|
+
if (Number.isFinite(x) && Number.isFinite(y)) pts.push({ x: x, y: y })
|
|
970
|
+
}
|
|
971
|
+
} else if (horizontalSide(source) !== horizontalSide(target)) {
|
|
972
|
+
const horizontal = horizontalSide(source) ? s : t
|
|
973
|
+
const vertical = horizontalSide(source) ? t : s
|
|
974
|
+
pts.push({ x: horizontal.x, y: vertical.y })
|
|
975
|
+
} else if (horizontalSide(source)) {
|
|
976
|
+
// 相对的左右两侧:从盒子上/下绕过去(e → w 走上边,w → e 走下边)。
|
|
977
|
+
const viaY = source === 'e' ? geo.y - inset : geo.y + geo.h + inset
|
|
978
|
+
pts.push({ x: s.x, y: viaY }, { x: t.x, y: viaY })
|
|
979
|
+
} else {
|
|
980
|
+
// 相对的上下两侧:从盒子左/右绕过去(n → s 走右边,s → n 走左边)。
|
|
981
|
+
const viaX = source === 'n' ? geo.x + geo.w + inset : geo.x - inset
|
|
982
|
+
pts.push({ x: viaX, y: s.y }, { x: viaX, y: t.y })
|
|
983
|
+
}
|
|
984
|
+
// 桩点 t 始终保留:末段要从 target 那侧的法线方向回到边框,少了它最后一段会斜着插进盒子。
|
|
985
|
+
pts.push(t, p1)
|
|
986
|
+
const orthogonal = isOrthogonalEdgeStyle(style) === true && curvedFromStyle(style) !== true
|
|
987
|
+
return orthogonal ? orthogonalizeRoute(dedupePoints(pts), style) : dedupePoints(pts)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function routeForEdge(edge, boxes, state) {
|
|
991
|
+
const own = Array.isArray(edge.points) ? edge.points : []
|
|
992
|
+
// storedRoute 用严格相等找端点,文档里 id 可能是数字也可能是字符串 —— 这里统一成字符串。
|
|
993
|
+
const probe = Object.assign({}, edge, {
|
|
994
|
+
from: edge.from === undefined || edge.from === null ? edge.from : String(edge.from),
|
|
995
|
+
to: edge.to === undefined || edge.to === null ? edge.to : String(edge.to),
|
|
996
|
+
})
|
|
997
|
+
const style = styleOf(edge)
|
|
998
|
+
// 自环:两端在同一个盒子上。storedRoute 会把两端裁到同一条边上、剩下的点不足两个,
|
|
999
|
+
// 整条边会静默消失;这里照客户端 selfLoopPath 的形状单独画(用户的折点照样穿过)。
|
|
1000
|
+
if (probe.from !== undefined && probe.from !== null && String(probe.from) === String(probe.to)) {
|
|
1001
|
+
const box = state.byId instanceof Map ? state.byId.get(String(probe.from)) : undefined
|
|
1002
|
+
if (box === undefined) return null
|
|
1003
|
+
return selfLoopRoute(box.geo, style, own)
|
|
1004
|
+
}
|
|
1005
|
+
// 没有折点的正交边:先用宿主路由器算出来的走线,再交给 storedRoute 裁两端的边框交点。
|
|
1006
|
+
// 只要有一个折点就用它(引擎常常只给一个拐点),空数组 = 这次没算出来。
|
|
1007
|
+
const solved = own.length === 0 ? state.routed.get(String(edge.id)) : null
|
|
1008
|
+
const usedSolved = Array.isArray(solved) && solved.length >= 1
|
|
1009
|
+
if (usedSolved) probe.points = solved
|
|
1010
|
+
const routed = storedRoute(probe, boxes)
|
|
1011
|
+
if (routed === null || routed.length < 2) return null
|
|
1012
|
+
const orthogonal = isOrthogonalEdgeStyle(style) === true && curvedFromStyle(style) !== true
|
|
1013
|
+
if (own.length > 0 || usedSolved) {
|
|
1014
|
+
// 存下来的折点只当"必经点",拐角要自己补 —— 否则会画出斜线段。
|
|
1015
|
+
return orthogonal ? orthogonalizeRoute(routed, style) : routed
|
|
1016
|
+
}
|
|
1017
|
+
// 引擎没接上(或不是正交边)时,退化成一条 Z 线,并在 warnings 里说清楚这次是估算。
|
|
1018
|
+
if (curvedFromStyle(style) === true) return routed
|
|
1019
|
+
const a = routed[0]
|
|
1020
|
+
const b = routed[routed.length - 1]
|
|
1021
|
+
if (Math.abs(b.x - a.x) < 0.5 || Math.abs(b.y - a.y) < 0.5) return routed
|
|
1022
|
+
state.warnings.push('连线 "' + String(edge.id) + '" 没有折点,正交走线按 Z 形近似')
|
|
1023
|
+
if (Math.abs(b.x - a.x) >= Math.abs(b.y - a.y)) {
|
|
1024
|
+
const mx = (a.x + b.x) / 2
|
|
1025
|
+
return [a, { x: mx, y: a.y }, { x: mx, y: b.y }, b]
|
|
1026
|
+
}
|
|
1027
|
+
const my = (a.y + b.y) / 2
|
|
1028
|
+
return [a, { x: a.x, y: my }, { x: b.x, y: my }, b]
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function markerIdFor(color, state) {
|
|
1032
|
+
if (state.markers.has(color)) return state.markers.get(color)
|
|
1033
|
+
const id = 'drawai-export-arrow-' + state.markers.size
|
|
1034
|
+
state.markers.set(color, id)
|
|
1035
|
+
return id
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function edgeSvg(edge, boxes, palette, state) {
|
|
1039
|
+
const style = styleOf(edge)
|
|
1040
|
+
const pts = routeForEdge(edge, boxes, state)
|
|
1041
|
+
if (pts === null) {
|
|
1042
|
+
state.warnings.push('连线 "' + String(edge.id) + '" 的两端都落不到任何节点上,已跳过')
|
|
1043
|
+
return null
|
|
1044
|
+
}
|
|
1045
|
+
const stroke = styleGet(style, 'strokeColor', null)
|
|
1046
|
+
const lineStroke = stroke !== null ? stroke : palette.line
|
|
1047
|
+
const curved = curvedFromStyle(style) === true
|
|
1048
|
+
// drawio 的圆角半径:arcSize(缺省 20)再除以 2 → 缺省 10px。
|
|
1049
|
+
const cornerRadius = styleGet(style, 'rounded', '0') === '1' ? Math.max(0, styleNumber(style, 'arcSize', 20) / 2) : 0
|
|
1050
|
+
|
|
1051
|
+
const labelFontSize = styleNumber(style, 'fontSize', 10)
|
|
1052
|
+
const hasLabel = typeof edge.label === 'string' && edge.label.length > 0
|
|
1053
|
+
let ownPos = hasLabel ? edgeLabelPointAt(pts, edge.labelX, edge.labelY, edge.labelOffsetX, edge.labelOffsetY) : null
|
|
1054
|
+
if (ownPos === null && hasLabel) ownPos = { x: pts[0].x, y: pts[0].y }
|
|
1055
|
+
const ownBox = ownPos === null ? null : labelBox(ownPos, edge.label, labelFontSize)
|
|
1056
|
+
|
|
1057
|
+
const gaps = []
|
|
1058
|
+
if (ownBox !== null) gaps.push.apply(gaps, labelGapsOnPath(pts, growBox(ownBox, 2)))
|
|
1059
|
+
const attached = Array.isArray(state.labels) ? state.labels : []
|
|
1060
|
+
for (let L = 0; L < attached.length; L += 1) {
|
|
1061
|
+
const item = attached[L]
|
|
1062
|
+
if (item === null || typeof item !== 'object' || String(item.edgeId) !== String(edge.id)) continue
|
|
1063
|
+
const text = typeof item.text === 'string' ? item.text : ''
|
|
1064
|
+
if (text.length === 0) continue
|
|
1065
|
+
const pos = edgeLabelPointAt(pts, item.x, item.y, item.offsetX, item.offsetY)
|
|
1066
|
+
if (pos === null) continue
|
|
1067
|
+
gaps.push.apply(gaps, labelGapsOnPath(pts, growBox(labelBox(pos, text, styleNumber(item.style, 'fontSize', 10)), 2)))
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const subPaths = gaps.length > 0 ? cutPathByGaps(pts, gaps) : [pts]
|
|
1071
|
+
const d = subPaths.map((sub) => pathOf(sub, cornerRadius, curved)).filter((text) => text.length > 0).join(' ')
|
|
1072
|
+
if (d.length === 0) return null
|
|
1073
|
+
|
|
1074
|
+
const arrow = arrowFromStyle(style)
|
|
1075
|
+
const dash = dashPatternFromStyle(style)
|
|
1076
|
+
const strokeOpacity = Math.max(0, Math.min(100, styleNumber(style, 'strokeOpacity', 100))) / 100
|
|
1077
|
+
const parts = []
|
|
1078
|
+
parts.push('<path' + attrs({
|
|
1079
|
+
d: d,
|
|
1080
|
+
fill: 'none',
|
|
1081
|
+
stroke: lineStroke,
|
|
1082
|
+
'stroke-width': fmt(styleNumber(style, 'strokeWidth', 1)),
|
|
1083
|
+
'stroke-opacity': strokeOpacity < 1 ? fmt(strokeOpacity) : null,
|
|
1084
|
+
'stroke-dasharray': dash,
|
|
1085
|
+
'stroke-linejoin': curved ? 'round' : null,
|
|
1086
|
+
'marker-end': arrow === 'none' || arrow === 'start' ? null : 'url(#' + markerIdFor(lineStroke, state) + '-end)',
|
|
1087
|
+
'marker-start': arrow === 'both' || arrow === 'start' ? 'url(#' + markerIdFor(lineStroke, state) + '-start)' : null,
|
|
1088
|
+
}) + '/>')
|
|
1089
|
+
|
|
1090
|
+
if (ownBox !== null) {
|
|
1091
|
+
const ownBg = styleGet(style, 'labelBackgroundColor', null)
|
|
1092
|
+
if (ownBg !== null) {
|
|
1093
|
+
const bg = parseCssColor(ownBg, palette.labelBg)
|
|
1094
|
+
parts.push('<rect' + attrs({
|
|
1095
|
+
x: fmt(ownBox.x), y: fmt(ownBox.y), width: fmt(ownBox.w), height: fmt(ownBox.h), rx: 2,
|
|
1096
|
+
fill: bg.color, 'fill-opacity': bg.opacity < 1 ? fmt(bg.opacity) : null,
|
|
1097
|
+
}) + '/>')
|
|
1098
|
+
}
|
|
1099
|
+
const fontColor = styleGet(style, 'fontColor', null)
|
|
1100
|
+
parts.push(textSvg({
|
|
1101
|
+
x: ownPos.x,
|
|
1102
|
+
top: ownPos.y + 1 - (labelLineHeight(labelFontSize) * String(edge.label).split('\n').length) / 2,
|
|
1103
|
+
lines: String(edge.label).split('\n'),
|
|
1104
|
+
fontSize: labelFontSize,
|
|
1105
|
+
lineHeight: labelLineHeight(labelFontSize),
|
|
1106
|
+
anchor: 'middle',
|
|
1107
|
+
fill: fontColor !== null ? fontColor : palette.text,
|
|
1108
|
+
opacity: Math.max(0, Math.min(100, styleNumber(style, 'textOpacity', 100))) / 100,
|
|
1109
|
+
}))
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
for (let L = 0; L < attached.length; L += 1) {
|
|
1113
|
+
const item = attached[L]
|
|
1114
|
+
if (item === null || typeof item !== 'object' || String(item.edgeId) !== String(edge.id)) continue
|
|
1115
|
+
const text = typeof item.text === 'string' ? item.text : ''
|
|
1116
|
+
if (text.length === 0) continue
|
|
1117
|
+
const pos = edgeLabelPointAt(pts, item.x, item.y, item.offsetX, item.offsetY)
|
|
1118
|
+
if (pos === null) continue
|
|
1119
|
+
const fontSize = styleNumber(item.style, 'fontSize', 10)
|
|
1120
|
+
const box = labelBox(pos, text, fontSize)
|
|
1121
|
+
parts.push('<rect' + attrs({
|
|
1122
|
+
x: fmt(box.x), y: fmt(box.y), width: fmt(box.w), height: fmt(box.h), rx: 2,
|
|
1123
|
+
fill: palette.labelBg, 'fill-opacity': 0.85,
|
|
1124
|
+
}) + '/>')
|
|
1125
|
+
parts.push(textSvg({
|
|
1126
|
+
x: pos.x,
|
|
1127
|
+
top: pos.y + 1 - (labelLineHeight(fontSize) * text.split('\n').length) / 2,
|
|
1128
|
+
lines: text.split('\n'),
|
|
1129
|
+
fontSize: fontSize,
|
|
1130
|
+
lineHeight: labelLineHeight(fontSize),
|
|
1131
|
+
anchor: 'middle',
|
|
1132
|
+
fill: styleGet(item.style, 'fontColor', null) || palette.text,
|
|
1133
|
+
opacity: 1,
|
|
1134
|
+
}))
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
return { svg: parts.join(''), points: pts, labelBox: ownBox }
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ── 组装 ────────────────────────────────────────────────────────────────
|
|
1141
|
+
|
|
1142
|
+
function normalizeOptions(options) {
|
|
1143
|
+
const raw = options !== null && typeof options === 'object' ? options : {}
|
|
1144
|
+
const padding = Number.isFinite(Number(raw.padding)) ? Math.max(0, Number(raw.padding)) : DEFAULT_PADDING
|
|
1145
|
+
// `background:'transparent'` 要能压过主题缺省那层白纸 —— 只写 `background:null` 分不清
|
|
1146
|
+
// "没给" 与 "明确要透明",缺省分支会把白底又铺回来。
|
|
1147
|
+
const transparent = raw.background === 'transparent' || raw.background === null
|
|
1148
|
+
return {
|
|
1149
|
+
padding: padding,
|
|
1150
|
+
theme: raw.theme === 'dark' ? 'dark' : 'light',
|
|
1151
|
+
transparent: transparent,
|
|
1152
|
+
background:
|
|
1153
|
+
typeof raw.background === 'string' && raw.background.length > 0 && raw.background !== 'transparent'
|
|
1154
|
+
? raw.background
|
|
1155
|
+
: null,
|
|
1156
|
+
fontFamily: typeof raw.fontFamily === 'string' && raw.fontFamily.length > 0 ? raw.fontFamily : SVG_FONT,
|
|
1157
|
+
title: typeof raw.title === 'string' && raw.title.length > 0 ? raw.title : '',
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* 文档对象 → SVG 文本。
|
|
1163
|
+
*
|
|
1164
|
+
* @returns {{ svg: string, width: number, height: number, viewBox: object, warnings: string[], stats: object }}
|
|
1165
|
+
*/
|
|
1166
|
+
export function buildExportSvg(doc, options) {
|
|
1167
|
+
const opts = normalizeOptions(options)
|
|
1168
|
+
const palette = opts.theme === 'dark' ? DARK : LIGHT
|
|
1169
|
+
const warnings = []
|
|
1170
|
+
const boxes = exportBoxes(doc)
|
|
1171
|
+
const byId = new Map()
|
|
1172
|
+
for (let i = 0; i < boxes.length; i += 1) byId.set(boxes[i].id, boxes[i])
|
|
1173
|
+
|
|
1174
|
+
let minX = Infinity
|
|
1175
|
+
let minY = Infinity
|
|
1176
|
+
let maxX = -Infinity
|
|
1177
|
+
let maxY = -Infinity
|
|
1178
|
+
const put = (x, y) => {
|
|
1179
|
+
const px = Number(x)
|
|
1180
|
+
const py = Number(y)
|
|
1181
|
+
if (Number.isFinite(px) === false || Number.isFinite(py) === false) return
|
|
1182
|
+
if (px < minX) minX = px
|
|
1183
|
+
if (py < minY) minY = py
|
|
1184
|
+
if (px > maxX) maxX = px
|
|
1185
|
+
if (py > maxY) maxY = py
|
|
1186
|
+
}
|
|
1187
|
+
const putRect = (rect) => {
|
|
1188
|
+
if (rect === null || rect === undefined) return
|
|
1189
|
+
put(rect.x, rect.y)
|
|
1190
|
+
put(rect.x + rect.w, rect.y + rect.h)
|
|
1191
|
+
}
|
|
1192
|
+
const putBounds = (bounds) => {
|
|
1193
|
+
if (bounds === null || bounds === undefined) return
|
|
1194
|
+
put(bounds.minX, bounds.minY)
|
|
1195
|
+
put(bounds.maxX, bounds.maxY)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const body = []
|
|
1199
|
+
for (let i = 0; i < boxes.length; i += 1) {
|
|
1200
|
+
const box = boxes[i]
|
|
1201
|
+
const shape = shapeSvg(box.node, box.geo, palette, warnings)
|
|
1202
|
+
// 标签必须与形状同组:跨节点叠放时,后画的节点矩形不能盖住前一个节点的字
|
|
1203
|
+
// (客户端每个节点就是一个 <g>,形状与标签都在里面)。
|
|
1204
|
+
const text = nodeLabelSvg(box.node, box.geo, palette)
|
|
1205
|
+
if (shape.length > 0 || text.length > 0) {
|
|
1206
|
+
body.push('<g' + attrs({ 'data-node-id': box.id }) + '>' + shape + text + '</g>')
|
|
1207
|
+
}
|
|
1208
|
+
putRect(box.geo)
|
|
1209
|
+
putBounds(labelPaintBounds(box.node, box.geo))
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const hidden = hiddenLayerIds(doc)
|
|
1213
|
+
const edges = Array.isArray(doc.edges) ? doc.edges : []
|
|
1214
|
+
const state = {
|
|
1215
|
+
warnings: warnings,
|
|
1216
|
+
labels: Array.isArray(doc.labels) ? doc.labels : [],
|
|
1217
|
+
markers: new Map(),
|
|
1218
|
+
byId: byId,
|
|
1219
|
+
routed: solveMissingRoutes(edges, boxes, warnings),
|
|
1220
|
+
}
|
|
1221
|
+
// 只数"真的画出来"的边:隐藏图层里的、两端都找不到节点的都不算 —— 否则 stats 会虚高,
|
|
1222
|
+
// exportTool 的"画布是空的"判定也会被一张实际只有隐藏单元的图骗过去。
|
|
1223
|
+
let paintedEdges = 0
|
|
1224
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
1225
|
+
const edge = edges[i]
|
|
1226
|
+
if (edge === null || typeof edge !== 'object') continue
|
|
1227
|
+
if (isHiddenCell(hidden, edge)) continue
|
|
1228
|
+
const painted = edgeSvg(edge, boxes, palette, state)
|
|
1229
|
+
if (painted === null) continue
|
|
1230
|
+
body.push('<g' + attrs({ 'data-edge-id': String(edge.id) }) + '>' + painted.svg + '</g>')
|
|
1231
|
+
for (let k = 0; k < painted.points.length; k += 1) put(painted.points[k].x, painted.points[k].y)
|
|
1232
|
+
putRect(painted.labelBox)
|
|
1233
|
+
paintedEdges += 1
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (Number.isFinite(minX) === false) {
|
|
1237
|
+
minX = 0
|
|
1238
|
+
minY = 0
|
|
1239
|
+
maxX = 1
|
|
1240
|
+
maxY = 1
|
|
1241
|
+
}
|
|
1242
|
+
const width = Math.max(1, maxX - minX + opts.padding * 2)
|
|
1243
|
+
const height = Math.max(1, maxY - minY + opts.padding * 2)
|
|
1244
|
+
const viewX = minX - opts.padding
|
|
1245
|
+
const viewY = minY - opts.padding
|
|
1246
|
+
|
|
1247
|
+
// 箭头 marker:每种线色一份(客户端用同一个 skin 色,彩色的线会配一个黑箭头,
|
|
1248
|
+
// 静态图里看着像画错了 —— 这里按线自己的颜色出)。
|
|
1249
|
+
const markers = []
|
|
1250
|
+
for (const color of state.markers.keys()) {
|
|
1251
|
+
const id = state.markers.get(color)
|
|
1252
|
+
markers.push('<marker' + attrs({
|
|
1253
|
+
id: id + '-end', viewBox: '0 0 10 10', refX: 9.5, refY: 5,
|
|
1254
|
+
markerWidth: 6, markerHeight: 6, orient: 'auto',
|
|
1255
|
+
}) + '><path' + attrs({ d: 'M 0 0 L 10 5 L 0 10 z', fill: color }) + '/></marker>')
|
|
1256
|
+
markers.push('<marker' + attrs({
|
|
1257
|
+
id: id + '-start', viewBox: '0 0 10 10', refX: 0.5, refY: 5,
|
|
1258
|
+
markerWidth: 6, markerHeight: 6, orient: 'auto',
|
|
1259
|
+
}) + '><path' + attrs({ d: 'M 10 0 L 0 5 L 10 10 z', fill: color }) + '/></marker>')
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const background = opts.background !== null ? opts.background : opts.transparent === true || opts.theme === 'dark' ? null : palette.page
|
|
1263
|
+
const head = [
|
|
1264
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1265
|
+
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' + attrs({
|
|
1266
|
+
width: fmt(Math.round(width * 100) / 100),
|
|
1267
|
+
height: fmt(Math.round(height * 100) / 100),
|
|
1268
|
+
viewBox: [viewX, viewY, width, height].map(fmt).join(' '),
|
|
1269
|
+
}) + '>',
|
|
1270
|
+
]
|
|
1271
|
+
if (opts.title.length > 0) head.push('<title>' + escapeXml(opts.title) + '</title>')
|
|
1272
|
+
if (markers.length > 0) head.push('<defs>' + markers.join('') + '</defs>')
|
|
1273
|
+
if (background !== null) {
|
|
1274
|
+
head.push('<rect' + attrs({ x: fmt(viewX), y: fmt(viewY), width: fmt(width), height: fmt(height), fill: background }) + '/>')
|
|
1275
|
+
}
|
|
1276
|
+
const svg = head.join('\n') + '\n' + body.join('\n') + '\n</svg>\n'
|
|
1277
|
+
|
|
1278
|
+
return {
|
|
1279
|
+
svg: svg,
|
|
1280
|
+
width: Math.round(width * 100) / 100,
|
|
1281
|
+
height: Math.round(height * 100) / 100,
|
|
1282
|
+
viewBox: { x: viewX, y: viewY, width: width, height: height },
|
|
1283
|
+
warnings: warnings,
|
|
1284
|
+
stats: { nodes: boxes.length, edges: paintedEdges },
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** 倍数的兜底(与客户端 exportOutputSize 同一组阈值):没给/不是数字 → 2,0 与负数 → 0.1。 */
|
|
1289
|
+
export function exportScaleFor(scale) {
|
|
1290
|
+
return Number.isFinite(Number(scale)) ? Math.max(0.1, Number(scale)) : 2
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** 从 `<svg …>` 头里读基准尺寸(buildExportSvg 一定会写 width/height)。 */
|
|
1294
|
+
function svgBaseSize(svg) {
|
|
1295
|
+
const head = String(svg === undefined || svg === null ? '' : svg)
|
|
1296
|
+
const end = head.indexOf('>')
|
|
1297
|
+
const open = end > 0 ? head.slice(0, end) : head
|
|
1298
|
+
const width = /\bwidth="([0-9.]+)"/.exec(open)
|
|
1299
|
+
const height = /\bheight="([0-9.]+)"/.exec(open)
|
|
1300
|
+
if (width === null || height === null) return null
|
|
1301
|
+
const w = Number(width[1])
|
|
1302
|
+
const h = Number(height[1])
|
|
1303
|
+
if (Number.isFinite(w) === false || Number.isFinite(h) === false || w <= 0 || h <= 0) return null
|
|
1304
|
+
return { width: w, height: h }
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** 尺寸上限(单边 MAX_SIDE、总量 MAX_PIXELS),超了直接给一句能读懂的话。 */
|
|
1308
|
+
function assertRasterFits(width, height) {
|
|
1309
|
+
if (width > MAX_SIDE || height > MAX_SIDE || width * height > MAX_PIXELS) {
|
|
1310
|
+
throw new Error('PNG 尺寸过大(' + width + '×' + height + ',上限 ' + MAX_SIDE + 'px/边、' +
|
|
1311
|
+
Math.round(MAX_PIXELS / 1000000) + 'MP);请调小 scale')
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* SVG → PNG。resvg 是可选的原生依赖(装不上时 SVG 那条路照走),所以按需动态 import。
|
|
1317
|
+
*
|
|
1318
|
+
* @returns {Promise<{ png: Buffer, width: number, height: number, scale: number }>}
|
|
1319
|
+
*/
|
|
1320
|
+
export async function rasterizeSvgToPng(svg, options) {
|
|
1321
|
+
const raw = options !== null && typeof options === 'object' ? options : {}
|
|
1322
|
+
let Resvg = null
|
|
1323
|
+
try {
|
|
1324
|
+
const mod = await import('@resvg/resvg-js')
|
|
1325
|
+
Resvg = mod.Resvg || (mod.default && mod.default.Resvg) || null
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
throw new Error('PNG 导出需要 @resvg/resvg-js(' + String(error && error.message ? error.message : error) + ');也可以用 format:"svg" 直接拿到矢量图')
|
|
1328
|
+
}
|
|
1329
|
+
if (typeof Resvg !== 'function') throw new Error('@resvg/resvg-js 的 Resvg 不可用')
|
|
1330
|
+
|
|
1331
|
+
const scale = exportScaleFor(raw.scale)
|
|
1332
|
+
// 先按基准尺寸算一遍像素数再栅格化:resvg 一次性分配整张位图,先 render 再检查等于让
|
|
1333
|
+
// 宿主进程先顶着内存跑一遍 —— 超大 scale 下那是 OOM,不是一句能读懂的错误。
|
|
1334
|
+
const base = svgBaseSize(svg)
|
|
1335
|
+
if (base !== null) {
|
|
1336
|
+
assertRasterFits(Math.max(1, Math.round(base.width * scale)), Math.max(1, Math.round(base.height * scale)))
|
|
1337
|
+
}
|
|
1338
|
+
const probe = new Resvg(svg, { fitTo: { mode: 'zoom', value: scale }, font: { loadSystemFonts: true } })
|
|
1339
|
+
const size = probe.render()
|
|
1340
|
+
// 兜底:resvg 自己的取整可能与上面的估算差一像素。
|
|
1341
|
+
assertRasterFits(size.width, size.height)
|
|
1342
|
+
const png = size.asPng()
|
|
1343
|
+
return { png: png, width: size.width, height: size.height, scale: scale }
|
|
1344
|
+
}
|