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.
@@ -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, '&amp;')
136
+ .replace(/</g, '&lt;')
137
+ .replace(/>/g, '&gt;')
138
+ }
139
+
140
+ function escapeAttr(text) {
141
+ return escapeXml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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
+ }