dsh-drawai 0.1.0

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,824 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ /* drawai-style-kernel — 宿主半边与客户端半边共用同一份源码;见下方说明。 */
3
+ /**
4
+ * drawio 风格样式内核 —— 宿主半边与客户端半边**共用同一份**源码。
5
+ *
6
+ * 用法:
7
+ * - 宿主半边(src/index.js):`import { parseStyle, ... } from './style-kernel.js'`,
8
+ * 构建时整个文件原样拷进 lib/style-kernel.js。
9
+ * - 客户端半边(src/client.js):不 import;tools/build.mjs 在构建时把本文件
10
+ * **去掉末尾的 export 语句**后原样内联到 bundle 的 factory 作用域里,
11
+ * 所以两边拿到的函数是同一份文本,不会漂移。
12
+ *
13
+ * 约束(构建期会强制):
14
+ * - 纯函数、零依赖、不引用任何全局(window/document/process 都不许);
15
+ * - **不许出现反引号**(客户端 bundle 靠加缩进套壳,模板字符串会让缩进不安全);
16
+ * - 顶层只放 `var` / `function` 声明与 `export { ... }` 一行。
17
+ *
18
+ * 数据模型(v2)与 drawio 的对应关系,逐条都有 drawio 源码依据:
19
+ * - 形状/配色不再是封闭枚举,而是 **开放 style 键值集合**,默认值省略:
20
+ * `Graph.prototype.defaultVertexStyle = {}`(js/grapheditor/Graph.js:17586)——
21
+ * 普通矩形就是**空样式**,这就是"rect = 没有 shape 键"的出处。
22
+ * - 虚线:`dashed=1` + `dashPattern`(mxConstants.js:1631,画布默认 '3 3':
23
+ * mxgraph/src/util/mxAbstractCanvas2D.js:154)。
24
+ * - 箭头:`endArrow` / `startArrow`(mxConstants.js:1466、:912),
25
+ * **键缺省 = 不画箭头**(mxgraph/src/shape/mxConnector.js:139 用 NONE 当 getValue 缺省)。
26
+ * drawio 新建连线的默认样式里显式带 `endArrow=classic`,我们也照写。
27
+ * - 正交路由:`edgeStyle=orthogonalEdgeStyle`(mxConstants.js:2554,
28
+ * 注册于 mxgraph/src/view/mxStyleRegistry.js:67);`edgeStyle=none` = 直线。
29
+ * - 端点桩点(引出段长度):`jettySize` / `sourceJettySize` / `targetJettySize`
30
+ * (mxConstants.js:2031;可为数字或 'auto',见 drawio docs/claude/libavoid-routing.md:259-280)。
31
+ * - 进出侧:`exitX`/`exitY`/`entryX`/`entryY`(mxConstants.js:904/912/946/954),
32
+ * 取值是**在顶点包围盒上的比例**(0=左/上,0.5=中,1=右/下)。
33
+ * - 避让:`libavoidRouting=1` 是 per-edge 的 style 键(libavoid-routing.md:210、:253)。
34
+ * - 折点归 `points`,与进出侧彻底分开;悬空端的自由点用 `sourcePoint`/`targetPoint`
35
+ * `{x,y}`(libavoid-routing.md:136-153),且**仅在该端没有真实顶点时才生效**
36
+ * (mxgraph/src/model/mxGeometry.js:100-113)。
37
+ * - 文档里存的是 drawio 的键;`shape`/`dashed`/`arrow`/颜色名这些**枚举只有工具与 UI 用**,
38
+ * 由本内核在两侧翻译,绝不落盘。
39
+ */
40
+
41
+ // ── 规范键序:落盘顺序稳定、diff 友好;不在表里的键(开放集合)按插入序排在后面 ──
42
+ var STYLE_KEY_ORDER = [
43
+ 'shape', 'ellipse', 'rhombus', 'rounded', 'arcSize', 'curved', 'direction', 'flipH', 'flipV',
44
+ 'whiteSpace', 'html', 'fontSize', 'fontFamily', 'fontColor', 'fontStyle',
45
+ 'fillColor', 'strokeColor', 'strokeWidth', 'dashed', 'dashPattern',
46
+ 'edgeStyle', 'orthogonalLoop', 'jettySize', 'sourceJettySize', 'targetJettySize',
47
+ 'exitX', 'exitY', 'entryX', 'entryY', 'libavoidRouting', 'endArrow', 'startArrow',
48
+ 'endFill', 'startFill', 'endSize', 'startSize', 'labelBackgroundColor',
49
+ ]
50
+
51
+ /** drawio 的 8 色经典调色板;'plain' 就是 drawio 缺省顶点的白底黑边。 */
52
+ var PALETTE = [
53
+ { name: 'plain', fill: '#ffffff', stroke: '#000000', font: '#000000' },
54
+ { name: 'blue', fill: '#dae8fc', stroke: '#6c8ebf', font: null },
55
+ { name: 'green', fill: '#d5e8d4', stroke: '#82b366', font: null },
56
+ { name: 'orange', fill: '#ffe6cc', stroke: '#d79b00', font: null },
57
+ { name: 'yellow', fill: '#fff2cc', stroke: '#d6b656', font: null },
58
+ { name: 'red', fill: '#f8cecc', stroke: '#b85450', font: null },
59
+ { name: 'purple', fill: '#e1d5e7', stroke: '#9673a6', font: null },
60
+ { name: 'grey', fill: '#f5f5f5', stroke: '#666666', font: null },
61
+ ]
62
+
63
+ /** drawio 缺省顶点样式:白底、黑边(所以空样式渲染出来就是它)。 */
64
+ var DEFAULT_FILL = '#ffffff'
65
+ var DEFAULT_STROKE = '#000000'
66
+ var DEFAULT_FONT = '#000000'
67
+
68
+ /**
69
+ * 形状枚举 → style 键。rect 映射成**空串**:drawio 里它就是"没有 shape 键"。
70
+ * 键名与 0.2.0(src/mxfile.js 的 SHAPE_STYLE)一致,方便两代互认。
71
+ *
72
+ * `text`(独立文字)用的是 drawio 的 `Editor.defaultTextStyle` **原样**:
73
+ * `text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;`
74
+ * (src/main/webapp/js/diagramly/Editor.js:376)。它是"没有边框没有填充的一段文字",
75
+ * 靠 `text` 这个裸键 + `strokeColor=none;fillColor=none` 表达 —— 所以换形状时必须把
76
+ * 这两个 none 一起清掉,否则"文字改成矩形"会得到一只看不见的矩形。
77
+ */
78
+ var NODE_SHAPE_STYLE = {
79
+ rect: '',
80
+ rounded: 'rounded=1',
81
+ text: 'text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0',
82
+ stadium: 'rounded=1;arcSize=50',
83
+ ellipse: 'ellipse=1',
84
+ diamond: 'rhombus=1',
85
+ parallelogram: 'shape=parallelogram',
86
+ cylinder: 'shape=cylinder3',
87
+ document: 'shape=document',
88
+ hexagon: 'shape=hexagon',
89
+ }
90
+
91
+ /** 形状枚举的顺序(UI 菜单、read 输出的稳定顺序)。顺序对齐 drawio 的形状面板。 */
92
+ var NODE_SHAPES = ['rect', 'rounded', 'text', 'stadium', 'ellipse', 'diamond', 'parallelogram', 'cylinder', 'document', 'hexagon']
93
+
94
+ /** 文字形状独有的"隐形"设置:离开文字形状时要清掉(它们不该跟着变成别的形状)。 */
95
+ var TEXT_SHAPE_ONLY_KEYS = { strokeColor: 'none', fillColor: 'none' }
96
+
97
+ /** 线型枚举 → style 键。solid 是缺省,落盘即"没有 dashed 键"。 */
98
+ var DASH_PRESETS = {
99
+ solid: null,
100
+ dashed: { dashed: '1' },
101
+ dotted: { dashed: '1', dashPattern: '1 2' },
102
+ }
103
+
104
+ /** 线型枚举顺序。 */
105
+ var DASH_KINDS = ['solid', 'dashed', 'dotted']
106
+
107
+ /** 箭头枚举 → style 键。none 是缺省(drawio 里键缺省就不画箭头),落盘即删键。 */
108
+ var ARROW_PRESETS = {
109
+ none: null,
110
+ end: { endArrow: 'classic' },
111
+ both: { endArrow: 'classic', startArrow: 'classic' },
112
+ start: { startArrow: 'classic' },
113
+ }
114
+
115
+ /** 箭头枚举顺序。 */
116
+ var ARROW_KINDS = ['end', 'both', 'none', 'start']
117
+
118
+ /** 四个进出侧 → drawio 的 exitX/exitY 比例(顶点包围盒上的比例坐标)。 */
119
+ var SIDE_FRACTION = {
120
+ n: { x: 0.5, y: 0 },
121
+ e: { x: 1, y: 0.5 },
122
+ s: { x: 0.5, y: 1 },
123
+ w: { x: 0, y: 0.5 },
124
+ }
125
+
126
+ /** 四个进出侧的顺序(UI 与预览里的稳定顺序:上右下左)。 */
127
+ var SIDES = ['n', 'e', 's', 'w']
128
+
129
+ /** 比例判定容差:drawio 的比例是浮点,比较必须带容差。 */
130
+ var FRACTION_EPS = 0.001
131
+
132
+ /** 缺省正交边样式:照抄 drawio 的 Graph.prototype.defaultEdgeStyle(Graph.js:17591),
133
+ * 再补上 drawio 新建连线时显式写的箭头与 html(无 html 键时 drawio 按纯文本渲染标签)。 */
134
+ var DEFAULT_EDGE_STYLE = 'edgeStyle=orthogonalEdgeStyle;rounded=0;jettySize=auto;orthogonalLoop=1;html=1;endArrow=classic;'
135
+
136
+ /** 缺省顶点样式:空串 = drawio 的 defaultVertexStyle。 */
137
+ var DEFAULT_NODE_STYLE = ''
138
+
139
+ function isObject(value) {
140
+ return value !== null && typeof value === 'object' && Array.isArray(value) === false
141
+ }
142
+
143
+ /** 归一化一个 style 值:数字转字符串、去空白;null/undefined 表示"删掉这个键"。 */
144
+ function styleValue(value) {
145
+ if (value === null || value === undefined) return null
146
+ if (typeof value === 'boolean') return value ? '1' : '0'
147
+ return String(value).trim()
148
+ }
149
+
150
+ /**
151
+ * 解析 style 串为键值对象。
152
+ *
153
+ * drawio 语法(原样照抄):以 ';' 分段;'k=v' 为键值;**单独一个键**(如 `ellipse;`)
154
+ * 等价于 `ellipse=1`;空段忽略。键值两侧空白去掉,值内部空白保留(`dashPattern=3 3`)。
155
+ */
156
+ function parseStyle(text) {
157
+ var out = {}
158
+ if (text === null || text === undefined) return out
159
+ if (isObject(text)) {
160
+ var keys = Object.keys(text)
161
+ for (var i = 0; i < keys.length; i += 1) {
162
+ var value = styleValue(text[keys[i]])
163
+ if (value !== null) out[keys[i]] = value
164
+ }
165
+ return out
166
+ }
167
+ var parts = String(text).split(';')
168
+ for (var p = 0; p < parts.length; p += 1) {
169
+ var part = parts[p].trim()
170
+ if (part.length === 0) continue
171
+ var eq = part.indexOf('=')
172
+ if (eq < 0) out[part] = '1'
173
+ else {
174
+ var key = part.slice(0, eq).trim()
175
+ if (key.length === 0) continue
176
+ out[key] = part.slice(eq + 1).trim()
177
+ }
178
+ }
179
+ return out
180
+ }
181
+
182
+ /** 把键值对象拼回 style 串:规范键序 + 未知键按插入序 + 结尾分号。空对象 → 空串。 */
183
+ function formatStyle(value) {
184
+ var map = parseStyle(value)
185
+ var seen = {}
186
+ var parts = []
187
+ var i
188
+ for (i = 0; i < STYLE_KEY_ORDER.length; i += 1) {
189
+ var known = STYLE_KEY_ORDER[i]
190
+ if (map[known] === undefined) continue
191
+ seen[known] = true
192
+ parts.push(known + '=' + map[known])
193
+ }
194
+ var rest = Object.keys(map)
195
+ for (i = 0; i < rest.length; i += 1) {
196
+ if (seen[rest[i]] === true) continue
197
+ parts.push(rest[i] + '=' + map[rest[i]])
198
+ }
199
+ return parts.length === 0 ? '' : parts.join(';') + ';'
200
+ }
201
+
202
+ /** 读一个键,接受 style 串或已解析的对象。 */
203
+ function styleGet(style, key, fallback) {
204
+ var map = isObject(style) ? style : parseStyle(style)
205
+ var value = map[key]
206
+ return value === undefined ? fallback : value
207
+ }
208
+
209
+ /**
210
+ * 读一个**数值型** style 键:键不存在 / 空值 / 非数字一律回落 fallback。
211
+ *
212
+ * 为什么必须单独有一个:`Number(null) === 0`,而 0 是有限数 —— 于是
213
+ * `numberOr(styleGet(style, 'fontSize', null), 12)` 这句看着像"取不到就用 12",
214
+ * 实际在缺省时返回的是 **0**。实测后果:节点文字与连线一起消失(fontSize=0、
215
+ * strokeWidth=0),而且**不报任何错**。数值型的 style 键一律走这里读。
216
+ */
217
+ function styleNumber(style, key, fallback) {
218
+ var raw = styleGet(style, key, null)
219
+ if (raw === null || raw === undefined || raw === '') return fallback
220
+ var n = Number(raw)
221
+ return Number.isFinite(n) ? n : fallback
222
+ }
223
+
224
+ /** 写/删一个键,返回新的 style 串(删键 = 回落 drawio 缺省)。 */
225
+ function styleSet(style, key, value) {
226
+ var map = parseStyle(style)
227
+ var normalized = styleValue(value)
228
+ if (normalized === null) delete map[key]
229
+ else map[key] = normalized
230
+ return formatStyle(map)
231
+ }
232
+
233
+ /** 批量写/删:keys 里值为 null 表示删。 */
234
+ function stylePatch(style, keys) {
235
+ var map = parseStyle(style)
236
+ var names = Object.keys(keys)
237
+ for (var i = 0; i < names.length; i += 1) {
238
+ var normalized = styleValue(keys[names[i]])
239
+ if (normalized === null) delete map[names[i]]
240
+ else map[names[i]] = normalized
241
+ }
242
+ return formatStyle(map)
243
+ }
244
+
245
+ /** 是否"等于 drawio 缺省"(空样式)。 */
246
+ function styleIsEmpty(style) {
247
+ return formatStyle(style).length === 0
248
+ }
249
+
250
+ // ── 形状 ─────────────────────────────────────────────────────────────────────
251
+
252
+ /**
253
+ * 从 style 键反推形状枚举。判据顺序与 drawio 渲染一致:
254
+ * shape= 的具体名字优先,其次 ellipse/rhombus 这两个裸键,再看 rounded/arcSize,最后 rect。
255
+ * 认不出的组合落回 rect —— 但**不丢键**(开放集合,未知键原样保留在文档里)。
256
+ */
257
+ function nodeShapeFromStyle(style) {
258
+ // 独立文字:drawio 用裸键 `text`(不是 `shape=text`)表达,所以要单独认一次。
259
+ if (styleGet(style, 'text', null) !== null) return 'text'
260
+ var shape = styleGet(style, 'shape', null)
261
+ if (shape === 'parallelogram') return 'parallelogram'
262
+ if (shape === 'cylinder3' || shape === 'cylinder') return 'cylinder'
263
+ if (shape === 'document') return 'document'
264
+ if (shape === 'hexagon') return 'hexagon'
265
+ if (shape === 'rhombus' || styleGet(style, 'rhombus', null) === '1') return 'diamond'
266
+ if (styleGet(style, 'ellipse', null) === '1' || shape === 'ellipse') return 'ellipse'
267
+ if (styleGet(style, 'rounded', null) === '1') {
268
+ var arc = Number(styleGet(style, 'arcSize', null))
269
+ if (Number.isFinite(arc) && arc >= 40) return 'stadium'
270
+ return 'rounded'
271
+ }
272
+ return 'rect'
273
+ }
274
+
275
+ /** 形状枚举 → 只含形状键的 style 串。 */
276
+ function styleFromNodeShape(shape) {
277
+ var preset = NODE_SHAPE_STYLE[shape]
278
+ return preset === undefined ? '' : preset
279
+ }
280
+
281
+ /** 换形状:先清掉所有形状键,再套新形状的键(其它键原样保留)。 */
282
+ function styleWithNodeShape(style, shape) {
283
+ var map = parseStyle(style)
284
+ var wasText = nodeShapeFromStyle(style) === 'text'
285
+ var shapeKeys = ['shape', 'ellipse', 'rhombus', 'rounded', 'arcSize', 'text']
286
+ for (var i = 0; i < shapeKeys.length; i += 1) delete map[shapeKeys[i]]
287
+ // 从"文字"换成别的形状:把它的隐形设置也清掉,否则换出来的矩形/椭圆是看不见的
288
+ // (文字形状靠 strokeColor=none;fillColor=none 才没有边框和底色)。
289
+ if (wasText && shape !== 'text') {
290
+ var noneKeys = Object.keys(TEXT_SHAPE_ONLY_KEYS)
291
+ for (var n = 0; n < noneKeys.length; n += 1) {
292
+ if (String(map[noneKeys[n]]).toLowerCase() === TEXT_SHAPE_ONLY_KEYS[noneKeys[n]]) delete map[noneKeys[n]]
293
+ }
294
+ }
295
+ var preset = parseStyle(styleFromNodeShape(shape))
296
+ var names = Object.keys(preset)
297
+ for (var p = 0; p < names.length; p += 1) map[names[p]] = preset[names[p]]
298
+ return formatStyle(map)
299
+ }
300
+
301
+ // ── 配色 ─────────────────────────────────────────────────────────────────────
302
+
303
+ /** 从 style 取填充/描边/字色,缺省即 drawio 缺省(白底黑边黑字)。 */
304
+ function colorsFromStyle(style) {
305
+ return {
306
+ fill: styleGet(style, 'fillColor', DEFAULT_FILL),
307
+ stroke: styleGet(style, 'strokeColor', DEFAULT_STROKE),
308
+ font: styleGet(style, 'fontColor', DEFAULT_FONT),
309
+ }
310
+ }
311
+
312
+ /** 精确命中调色板才给名字("开放集合"里认不出的颜色就是没有名字)。 */
313
+ function colorNameFromStyle(style) {
314
+ var fill = String(styleGet(style, 'fillColor', DEFAULT_FILL)).toLowerCase()
315
+ var stroke = String(styleGet(style, 'strokeColor', DEFAULT_STROKE)).toLowerCase()
316
+ for (var i = 0; i < PALETTE.length; i += 1) {
317
+ if (PALETTE[i].fill.toLowerCase() === fill && PALETTE[i].stroke.toLowerCase() === stroke) return PALETTE[i].name
318
+ }
319
+ return null
320
+ }
321
+
322
+ /** 调色板名 → style 键;'plain' 即 drawio 缺省,落盘就是删掉这两个键。 */
323
+ function styleWithColorName(style, name) {
324
+ var entry = null
325
+ for (var i = 0; i < PALETTE.length; i += 1) if (PALETTE[i].name === name) entry = PALETTE[i]
326
+ if (entry === null) return style
327
+ if (entry.name === 'plain') {
328
+ return stylePatch(style, { fillColor: null, strokeColor: null, fontColor: null })
329
+ }
330
+ var patch = { fillColor: entry.fill, strokeColor: entry.stroke }
331
+ patch.fontColor = entry.font === null ? null : entry.font
332
+ return stylePatch(style, patch)
333
+ }
334
+
335
+ /** 直接给两个色值(客户端取色器用)。 */
336
+ function styleWithColors(style, fill, stroke, font) {
337
+ var patch = {}
338
+ patch.fillColor = fill === undefined ? null : fill
339
+ patch.strokeColor = stroke === undefined ? null : stroke
340
+ if (font !== undefined) patch.fontColor = font
341
+ return stylePatch(style, patch)
342
+ }
343
+
344
+ /**
345
+ * 独立文字的"配色":文字没有填充与描边,能换的只有**字色**。
346
+ *
347
+ * 所以对文字元素,调色板要把颜色写到 `fontColor` 上而不是 fillColor/strokeColor ——
348
+ * 否则用户点了一圈颜色,屏幕上一点变化都没有(填充/描边被文字形状忽略)。
349
+ */
350
+ function textColorNameFromStyle(style) {
351
+ var font = String(styleGet(style, 'fontColor', DEFAULT_FONT)).toLowerCase()
352
+ for (var i = 0; i < PALETTE.length; i += 1) {
353
+ var want = String(PALETTE[i].name === 'plain' ? DEFAULT_FONT : PALETTE[i].stroke).toLowerCase()
354
+ if (want === font) return PALETTE[i].name
355
+ }
356
+ return null
357
+ }
358
+
359
+ /** 文字元素的调色板:把颜色写成 fontColor('plain' = 删键回缺省字色)。 */
360
+ function styleWithTextColorName(style, name) {
361
+ var entry = null
362
+ for (var i = 0; i < PALETTE.length; i += 1) if (PALETTE[i].name === name) entry = PALETTE[i]
363
+ if (entry === null) return style
364
+ if (entry.name === 'plain') return stylePatch(style, { fontColor: null })
365
+ return stylePatch(style, { fontColor: entry.stroke })
366
+ }
367
+
368
+ // ── 线型 ─────────────────────────────────────────────────────────────────────
369
+
370
+ /** style → 线型枚举。dashed 不在就是实线;dashPattern 认得出点线就报 dotted,否则 custom。 */
371
+ function dashFromStyle(style) {
372
+ if (styleGet(style, 'dashed', null) !== '1') return 'solid'
373
+ var pattern = styleGet(style, 'dashPattern', null)
374
+ if (pattern === null || pattern === '' || pattern === '3 3') return 'dashed'
375
+ if (pattern === '1 2' || pattern === '1 1') return 'dotted'
376
+ return 'custom'
377
+ }
378
+
379
+ /** 线型枚举 → style(custom 需要显式传 pattern)。 */
380
+ function styleWithDash(style, kind, pattern) {
381
+ var cleared = stylePatch(style, { dashed: null, dashPattern: null })
382
+ if (kind === 'solid' || kind === null || kind === undefined) return cleared
383
+ if (kind === 'custom') return stylePatch(cleared, { dashed: '1', dashPattern: pattern === undefined ? '3 3' : pattern })
384
+ var preset = DASH_PRESETS[kind]
385
+ if (preset === undefined || preset === null) return cleared
386
+ return stylePatch(cleared, preset)
387
+ }
388
+
389
+ /** 读虚线间距(给渲染用):solid 返回 null,否则返回 drawio 画布缺省 '3 3'。 */
390
+ function dashPatternFromStyle(style) {
391
+ if (dashFromStyle(style) === 'solid') return null
392
+ return styleGet(style, 'dashPattern', '3 3')
393
+ }
394
+
395
+ // ── 箭头 ─────────────────────────────────────────────────────────────────────
396
+
397
+ /** 是否算"有箭头":drawio 里只要不是 none/缺省就画。 */
398
+ function arrowValueIsSet(value) {
399
+ return value !== null && value !== undefined && value !== '' && value !== 'none' && value !== '0'
400
+ }
401
+
402
+ /** style → 箭头枚举。 */
403
+ function arrowFromStyle(style) {
404
+ var end = arrowValueIsSet(styleGet(style, 'endArrow', null))
405
+ var start = arrowValueIsSet(styleGet(style, 'startArrow', null))
406
+ if (end && start) return 'both'
407
+ if (end) return 'end'
408
+ if (start) return 'start'
409
+ return 'none'
410
+ }
411
+
412
+ /** 箭头枚举 → style。none 即删键(= drawio 缺省不画)。 */
413
+ function styleWithArrow(style, kind) {
414
+ var cleared = stylePatch(style, { endArrow: null, startArrow: null })
415
+ if (kind === null || kind === undefined || kind === 'none') return cleared
416
+ var preset = ARROW_PRESETS[kind]
417
+ if (preset === undefined || preset === null) return cleared
418
+ return stylePatch(cleared, preset)
419
+ }
420
+
421
+ // ── 端点:进出侧(exitX/exitY/entryX/entryY)─────────────────────────────────
422
+
423
+ /** 端别 → 键名。source 端用 exitX/exitY,target 端用 entryX/entryY(drawio 命名如此)。 */
424
+ function sideKeysFor(end) {
425
+ return end === 'source' ? ['exitX', 'exitY'] : ['entryX', 'entryY']
426
+ }
427
+
428
+ /** 比例 → 最近的侧;不是已知的四个侧中心就返回 null(保留了任意比例,只是没有侧名)。 */
429
+ function sideFromFraction(x, y) {
430
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null
431
+ for (var i = 0; i < SIDES.length; i += 1) {
432
+ var f = SIDE_FRACTION[SIDES[i]]
433
+ if (Math.abs(f.x - x) <= FRACTION_EPS && Math.abs(f.y - y) <= FRACTION_EPS) return SIDES[i]
434
+ }
435
+ return null
436
+ }
437
+
438
+ /** 读某一端的进出侧:没有约束返回 null(= 由路由器自己挑边)。 */
439
+ function sideFromStyle(style, end) {
440
+ var keys = sideKeysFor(end)
441
+ var x = styleGet(style, keys[0], null)
442
+ var y = styleGet(style, keys[1], null)
443
+ if (x === null || y === null) return null
444
+ return sideFromFraction(Number(x), Number(y))
445
+ }
446
+
447
+ /** 读某一端的比例约束(可能不是侧中心);没有返回 null。 */
448
+ function fractionFromStyle(style, end) {
449
+ var keys = sideKeysFor(end)
450
+ var x = styleGet(style, keys[0], null)
451
+ var y = styleGet(style, keys[1], null)
452
+ if (x === null || y === null) return null
453
+ var nx = Number(x)
454
+ var ny = Number(y)
455
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) return null
456
+ return { x: nx, y: ny }
457
+ }
458
+
459
+ /** 写/清某一端的进出侧约束;side 为 null 时删键(回到"路由器自己挑")。 */
460
+ function styleWithSide(style, end, side) {
461
+ var keys = sideKeysFor(end)
462
+ var patch = {}
463
+ if (side === null || side === undefined) {
464
+ patch[keys[0]] = null
465
+ patch[keys[1]] = null
466
+ } else {
467
+ var fraction = SIDE_FRACTION[side]
468
+ if (fraction === undefined) return formatStyle(style)
469
+ patch[keys[0]] = String(fraction.x)
470
+ patch[keys[1]] = String(fraction.y)
471
+ }
472
+ return stylePatch(style, patch)
473
+ }
474
+
475
+ // ── 端点:桩点长度、正交路由、避让 ───────────────────────────────────────────
476
+
477
+ /** edgeStyle 键:缺省按 drawio 新建连线的缺省值当正交(Graph.js:17591)。 */
478
+ function edgeStyleValueFromStyle(style) {
479
+ return styleGet(style, 'edgeStyle', 'orthogonalEdgeStyle')
480
+ }
481
+
482
+ /** 是否走正交路由('none' = 直线;其余值不认识也按正交画,但键原样保留)。 */
483
+ function isOrthogonalEdgeStyle(style) {
484
+ return edgeStyleValueFromStyle(style) !== 'none'
485
+ }
486
+
487
+ /**
488
+ * 连线的**线型**四选一:直线 / 直角折线 / 圆角折线 / 曲线。
489
+ *
490
+ * 这是把 drawio 的两组键**压平**成一个选择(drawio 的面板里它们是两组):
491
+ * · 走线:Straight(直连)vs Orthogonal(正交折线)—— `edgeStyle`;
492
+ * · 拐角:Sharp / Rounded / Curved —— `rounded=1`(**只在折点处倒角**)与 `curved=1`
493
+ * (把整条折线抹成平滑曲线),见 Format.js:6543 的 `['sharp','rounded','curved']`。
494
+ * 能压平是因为四个组合里只有这四个是有意义的:直线没有折点可倒角,
495
+ * 曲线与圆角互斥(drawio 的 paintCurvedLine 优先,rounded 根本用不上)。
496
+ *
497
+ * `straight` 落成 `edgeStyle=none`:drawio 的 "Straight" 其实是**删掉 edgeStyle 键**
498
+ * (mxGraph 认不出的值就退回折线),而我们的渲染器把"没有键"当正交 —— 所以直线上必须
499
+ * 显式写 `none`,两边才看到同一条线(drawio 那边认不出 `none`,同样按直线画)。
500
+ */
501
+ function lineKindFromStyle(style) {
502
+ if (styleGet(style, 'curved', null) === '1') return 'curved'
503
+ if (isOrthogonalEdgeStyle(style) === false) return 'straight'
504
+ return styleGet(style, 'rounded', '0') === '1' ? 'rounded' : 'sharp'
505
+ }
506
+
507
+ /** 写线型:'straight' | 'sharp' | 'rounded' | 'curved'('orthogonal' 当 sharp 的别名;认不出的值原样返回)。 */
508
+ function styleWithLineKind(style, kind) {
509
+ // 直线没有折点可倒角:把 rounded/curved 一起清掉,文件里不留没用的键。
510
+ if (kind === 'straight') return stylePatch(style, { curved: null, rounded: null, edgeStyle: 'none' })
511
+ // 曲线与圆角互斥:curved 优先,顺手把 rounded 清掉(不然切回圆角时状态是"两个都开着")。
512
+ if (kind === 'curved') return stylePatch(style, { rounded: null, curved: '1' })
513
+ if (kind === 'rounded') return stylePatch(style, { curved: null, rounded: '1', edgeStyle: 'orthogonalEdgeStyle' })
514
+ if (kind === 'sharp' || kind === 'orthogonal') return stylePatch(style, { curved: null, rounded: null, edgeStyle: 'orthogonalEdgeStyle' })
515
+ return style
516
+ }
517
+
518
+ /** `curved=1`:把折线抹成平滑曲线(drawio 的 STYLE_CURVED,mxPolyline.paintCurvedLine)。 */
519
+ function curvedFromStyle(style) {
520
+ return styleGet(style, 'curved', null) === '1'
521
+ }
522
+
523
+ /** 某一端的桩点长度:'auto' 或缺省 → null(由渲染端用缺省值);数字 → 数值。 */
524
+ function jettyFromStyle(style, end) {
525
+ var key = end === 'source' ? 'sourceJettySize' : end === 'target' ? 'targetJettySize' : 'jettySize'
526
+ var value = styleGet(style, key, null)
527
+ if (value === null) value = end === undefined ? null : styleGet(style, 'jettySize', null)
528
+ if (value === null || value === '' || value === 'auto') return null
529
+ var n = Number(value)
530
+ return Number.isFinite(n) ? n : null
531
+ }
532
+
533
+ /** 写桩点长度:数字或 'auto';null 删键。 */
534
+ function styleWithJetty(style, value) {
535
+ return stylePatch(style, { jettySize: value === null || value === undefined ? null : String(value) })
536
+ }
537
+
538
+ /** 避让开关(drawio 的 per-edge 键 `libavoidRouting`)。 */
539
+ function avoidFromStyle(style) {
540
+ return styleGet(style, 'libavoidRouting', '0') === '1'
541
+ }
542
+
543
+ /** 写避让开关:开 = libavoidRouting=1,关 = 删键(回落缺省关)。 */
544
+ function styleWithAvoid(style, on) {
545
+ return stylePatch(style, { libavoidRouting: on === true ? '1' : null })
546
+ }
547
+
548
+ // ── 折点与悬空端 ─────────────────────────────────────────────────────────────
549
+
550
+ /** 一个点是否可用(有限数)。 */
551
+ function normalizePoint(value) {
552
+ if (!isObject(value)) return null
553
+ var x = Number(value.x)
554
+ var y = Number(value.y)
555
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null
556
+ return { x: x, y: y }
557
+ }
558
+
559
+ /** 归一化折点数组:丢掉非法项、保持顺序;空数组归一成 null(= 没有折点)。 */
560
+ function normalizePoints(value) {
561
+ if (Array.isArray(value) === false) return null
562
+ var out = []
563
+ for (var i = 0; i < value.length; i += 1) {
564
+ var point = normalizePoint(value[i])
565
+ if (point !== null) out.push(point)
566
+ }
567
+ return out.length === 0 ? null : out
568
+ }
569
+
570
+ /** 两点是否同一位置(浮点容差)。 */
571
+ function pointsEqual(a, b, eps) {
572
+ var tolerance = eps === undefined ? 0.5 : eps
573
+ if (a === null || a === undefined) return b === null || b === undefined
574
+ if (b === null || b === undefined) return false
575
+ return Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance
576
+ }
577
+
578
+ /** 该端是否有真实顶点(drawio:有顶点时 sourcePoint/targetPoint 被忽略)。 */
579
+ function edgeTerminalId(edge, end) {
580
+ var id = end === 'source' ? edge.from : edge.to
581
+ return typeof id === 'string' && id.length > 0 ? id : null
582
+ }
583
+
584
+ /** 该端的自由点(悬空端才有效):返回归一化点或 null。 */
585
+ function edgeFreePoint(edge, end) {
586
+ if (edgeTerminalId(edge, end) !== null) return null
587
+ return normalizePoint(end === 'source' ? edge.sourcePoint : edge.targetPoint)
588
+ }
589
+
590
+ /**
591
+ * 一次性几何迁移:把**节点几何对齐到整格**、**折点与自由端点对齐到半格**。
592
+ *
593
+ * 为什么需要它:画布自己的手势是吸附的,但文档还可能从别处来 —— drawio 手画的图、
594
+ * AI 早期写下的坐标、旧版本估宽留下的 186/56 这种非整格尺寸。它们不会自己变整齐,
595
+ * 而"差几像素的台阶"正是之前反复修的那类毛病(同一条线上两个段把手、连线抖动)的温床。
596
+ *
597
+ * 只动**几何**:style 键、标签、两端约束、层级、其他页一律不碰(写回仍然是无损手术)。
598
+ * 尺寸下限取 minW/minH(默认一格)—— 传 0 表示不夹。
599
+ *
600
+ * @returns { doc, changes } —— changes 是被改动过的字段个数(0 表示本来就在格线上)
601
+ */
602
+ function snapDocGeometry(doc, options) {
603
+ var opt = isObject(options) ? options : {}
604
+ var grid = Number.isFinite(Number(opt.grid)) && Number(opt.grid) > 0 ? Number(opt.grid) : 10
605
+ var edgeGrid = Number.isFinite(Number(opt.edgeGrid)) && Number(opt.edgeGrid) > 0 ? Number(opt.edgeGrid) : grid / 2
606
+ var minW = Number.isFinite(Number(opt.minW)) && Number(opt.minW) > 0 ? Number(opt.minW) : grid
607
+ var minH = Number.isFinite(Number(opt.minH)) && Number(opt.minH) > 0 ? Number(opt.minH) : grid
608
+ var snap = function (value, unit) {
609
+ var n = Number(value)
610
+ return Number.isFinite(n) ? Math.round(n / unit) * unit : 0
611
+ }
612
+ var changes = 0
613
+ var nodesIn = Array.isArray(doc.nodes) ? doc.nodes : []
614
+ var nodes = []
615
+ var i
616
+ for (i = 0; i < nodesIn.length; i += 1) {
617
+ var node = Object.assign({}, nodesIn[i])
618
+ var x = snap(node.x, grid)
619
+ var y = snap(node.y, grid)
620
+ var w = Math.max(minW, snap(node.w, grid))
621
+ var h = Math.max(minH, snap(node.h, grid))
622
+ if (x !== node.x) changes += 1
623
+ if (y !== node.y) changes += 1
624
+ if (w !== node.w) changes += 1
625
+ if (h !== node.h) changes += 1
626
+ node.x = x
627
+ node.y = y
628
+ node.w = w
629
+ node.h = h
630
+ nodes.push(node)
631
+ }
632
+ var edgesIn = Array.isArray(doc.edges) ? doc.edges : []
633
+ var edges = []
634
+ for (i = 0; i < edgesIn.length; i += 1) {
635
+ var edge = Object.assign({}, edgesIn[i])
636
+ if (Array.isArray(edge.points)) {
637
+ var points = []
638
+ for (var k = 0; k < edge.points.length; k += 1) {
639
+ var px = snap(edge.points[k].x, edgeGrid)
640
+ var py = snap(edge.points[k].y, edgeGrid)
641
+ if (px !== edge.points[k].x) changes += 1
642
+ if (py !== edge.points[k].y) changes += 1
643
+ points.push({ x: px, y: py })
644
+ }
645
+ edge.points = points
646
+ }
647
+ var ends = ['sourcePoint', 'targetPoint']
648
+ for (var e = 0; e < ends.length; e += 1) {
649
+ var point = edge[ends[e]]
650
+ if (point === undefined || point === null) continue
651
+ var fx = snap(point.x, edgeGrid)
652
+ var fy = snap(point.y, edgeGrid)
653
+ if (fx !== point.x) changes += 1
654
+ if (fy !== point.y) changes += 1
655
+ edge[ends[e]] = { x: fx, y: fy }
656
+ }
657
+ edges.push(edge)
658
+ }
659
+ return {
660
+ doc: {
661
+ version: doc.version,
662
+ revision: doc.revision,
663
+ meta: doc.meta,
664
+ nodes: nodes,
665
+ edges: edges,
666
+ labels: doc.labels,
667
+ },
668
+ changes: changes,
669
+ }
670
+ }
671
+
672
+ /**
673
+ * 自定义属性(drawio 的"编辑数据"):只保留字符串值。
674
+ *
675
+ * 为什么单列一个函数:数据最终要写成 XML 属性,非字符串值(嵌套对象、数字以外的东西)
676
+ * 没有意义,与其在写回时悄悄 String() 一下,不如在入口就收敛成字符串。
677
+ */
678
+ function copyData(data) {
679
+ var out = {}
680
+ if (isObject(data) === false) return out
681
+ var keys = Object.keys(data)
682
+ for (var i = 0; i < keys.length; i += 1) {
683
+ var key = keys[i]
684
+ if (key === 'id' || key === 'label') continue
685
+ var value = data[key]
686
+ if (value === undefined || value === null) continue
687
+ out[key] = typeof value === 'string' ? value : String(value)
688
+ }
689
+ return out
690
+ }
691
+
692
+ /**
693
+ * 文档归一化(字段清洗)。两侧半边都用它,保证读写同一套判据。
694
+ *
695
+ * 载体是 `.drawio`(drawio 的 mxfile)之后,这里**不再有 v1 迁移**:
696
+ * 盘上只有一种格式,`shape:'rect'` / `style:'blue'` / `dash:'dashed'` 那套语义枚举
697
+ * 随 `.dshd.json` 一起退场了。留着的迁移代码只会让人以为还有第二种格式。
698
+ *
699
+ * `revision` 是**文件内容指纹**(字符串),原样透传 —— 这里曾经把它 `Number()` 一下,
700
+ * 于是十六进制指纹里只要有一个字母就变成 0,客户端的乐观锁直接失效。
701
+ */
702
+ function normalizeDrawioDoc(raw) {
703
+ var source = isObject(raw) ? raw : {}
704
+
705
+ var nodesIn = Array.isArray(source.nodes) ? source.nodes : []
706
+ var edgesIn = Array.isArray(source.edges) ? source.edges : []
707
+ var nodes = []
708
+ var i
709
+
710
+ for (i = 0; i < nodesIn.length; i += 1) {
711
+ var rawNode = isObject(nodesIn[i]) ? nodesIn[i] : {}
712
+ var out = { id: rawNode.id }
713
+ if (typeof rawNode.label === 'string') out.label = rawNode.label
714
+ out.style = formatStyle(rawNode.style)
715
+ out.x = Number.isFinite(Number(rawNode.x)) ? Number(rawNode.x) : 0
716
+ out.y = Number.isFinite(Number(rawNode.y)) ? Number(rawNode.y) : 0
717
+ out.w = Number.isFinite(Number(rawNode.w)) ? Number(rawNode.w) : 0
718
+ out.h = Number.isFinite(Number(rawNode.h)) ? Number(rawNode.h) : 0
719
+ // 编辑数据(drawio 用户对象上的自定义属性):只收字符串值,原样带上。
720
+ var data = rawNode.data
721
+ if (isObject(data)) out.data = copyData(data)
722
+ // 属于哪个图层(渲染时隐藏层整层不画)。
723
+ if (typeof rawNode.layer === 'string' && rawNode.layer.length > 0) out.layer = rawNode.layer
724
+ // 容器父级(**只读信息**):画布按绝对位置显示,层级在文件里保留;移动容器不会带走子单元。
725
+ if (typeof rawNode.parent === 'string' && rawNode.parent.length > 0) out.parent = rawNode.parent
726
+ nodes.push(out)
727
+ }
728
+
729
+ var edges = []
730
+ for (i = 0; i < edgesIn.length; i += 1) {
731
+ var edge = isObject(edgesIn[i]) ? edgesIn[i] : {}
732
+ var item = { id: edge.id, from: edge.from, to: edge.to }
733
+ if (typeof edge.label === 'string' && edge.label.length > 0) item.label = edge.label
734
+ item.style = formatStyle(edge.style === undefined ? DEFAULT_EDGE_STYLE : edge.style)
735
+ var points = normalizePoints(edge.points)
736
+ if (points !== null) item.points = points
737
+ var sourcePoint = normalizePoint(edge.sourcePoint)
738
+ if (sourcePoint !== null) item.sourcePoint = sourcePoint
739
+ var targetPoint = normalizePoint(edge.targetPoint)
740
+ if (targetPoint !== null) item.targetPoint = targetPoint
741
+ // 边标签的位置(drawio 的 mxGeometry x/y + offset):拖过标签才有,缺省 = 弧长中点。
742
+ // 必须原样穿过归一化 —— 丢了它,画布会把用户拖过的标签画回中点(和 labels 那次踩的同一个坑)。
743
+ var hasPos = edge.labelX !== undefined && edge.labelX !== null && edge.labelY !== undefined && edge.labelY !== null
744
+ if (hasPos && Number.isFinite(Number(edge.labelX)) && Number.isFinite(Number(edge.labelY))) {
745
+ item.labelX = Number(edge.labelX)
746
+ item.labelY = Number(edge.labelY)
747
+ var offX = Number(edge.labelOffsetX)
748
+ var offY = Number(edge.labelOffsetY)
749
+ if (Number.isFinite(offX) && offX !== 0) item.labelOffsetX = offX
750
+ if (Number.isFinite(offY) && offY !== 0) item.labelOffsetY = offY
751
+ }
752
+ if (isObject(edge.data)) item.data = copyData(edge.data)
753
+ if (typeof edge.layer === 'string' && edge.layer.length > 0) item.layer = edge.layer
754
+ edges.push(item)
755
+ }
756
+
757
+ var meta = isObject(source.meta) ? source.meta : {}
758
+ // drawio 的独立边标签单元:**只读**,但必须原样穿过归一化 ——
759
+ // 它决定了渲染出来的标签位置。丢掉它 = 画布上看不见那些标签(实测踩过:
760
+ // 宿主读出来有、客户端归一化时被抹掉,于是"读到了"却什么都不显示)。
761
+ var labelsIn = Array.isArray(source.labels) ? source.labels : []
762
+ var labels = []
763
+ for (i = 0; i < labelsIn.length; i += 1) {
764
+ var raw = isObject(labelsIn[i]) ? labelsIn[i] : {}
765
+ if (typeof raw.id !== 'string') continue
766
+ labels.push({
767
+ id: raw.id,
768
+ text: typeof raw.text === 'string' ? raw.text : '',
769
+ edgeId: typeof raw.edgeId === 'string' && raw.edgeId.length > 0 ? raw.edgeId : null,
770
+ x: Number.isFinite(Number(raw.x)) ? Number(raw.x) : 0,
771
+ y: Number.isFinite(Number(raw.y)) ? Number(raw.y) : 0,
772
+ offsetX: Number.isFinite(Number(raw.offsetX)) ? Number(raw.offsetX) : 0,
773
+ offsetY: Number.isFinite(Number(raw.offsetY)) ? Number(raw.offsetY) : 0,
774
+ relative: raw.relative === true,
775
+ style: typeof raw.style === 'string' ? raw.style : '',
776
+ ...(typeof raw.layer === 'string' && raw.layer.length > 0 ? { layer: raw.layer } : {}),
777
+ })
778
+ }
779
+ // 图层:id + 名字 + 显示/隐藏 + 锁定(顺序 = 覆盖顺序)。缺省可见、未锁。
780
+ var layersIn = Array.isArray(source.layers) ? source.layers : []
781
+ var layers = []
782
+ for (i = 0; i < layersIn.length; i += 1) {
783
+ var rawLayer = isObject(layersIn[i]) ? layersIn[i] : {}
784
+ if (typeof rawLayer.id !== 'string' || rawLayer.id.length === 0) continue
785
+ layers.push({
786
+ id: rawLayer.id,
787
+ name: typeof rawLayer.name === 'string' ? rawLayer.name : '',
788
+ visible: rawLayer.visible !== false,
789
+ locked: rawLayer.locked === true,
790
+ })
791
+ }
792
+ return {
793
+ version: 2,
794
+ revision: typeof source.revision === 'string' ? source.revision : Number.isFinite(Number(source.revision)) && source.revision !== undefined && source.revision !== null ? String(source.revision) : '',
795
+ meta: meta,
796
+ nodes: nodes,
797
+ edges: edges,
798
+ labels: labels,
799
+ layers: layers,
800
+ // 纸张尺寸:文件里说了才带(AI 做布局/导出建议时要看它)。
801
+ // 丢它的后果和 layers 那次一样 —— 一存一开尺寸就变回缺省,而且没人会发现。
802
+ ...(isObject(source.page) && Number(source.page.w) > 0 && Number(source.page.h) > 0
803
+ ? { page: { w: Number(source.page.w), h: Number(source.page.h) } }
804
+ : {}),
805
+ }
806
+ }
807
+
808
+ export {
809
+ STYLE_KEY_ORDER, PALETTE, NODE_SHAPES, NODE_SHAPE_STYLE, DASH_KINDS, ARROW_KINDS, SIDES,
810
+ DEFAULT_FILL, DEFAULT_STROKE, DEFAULT_FONT, DEFAULT_NODE_STYLE, DEFAULT_EDGE_STYLE,
811
+ parseStyle, formatStyle, styleGet, styleSet, stylePatch, styleIsEmpty, styleNumber,
812
+ nodeShapeFromStyle, styleFromNodeShape, styleWithNodeShape,
813
+ colorsFromStyle, colorNameFromStyle, styleWithColorName, styleWithColors,
814
+ textColorNameFromStyle, styleWithTextColorName,
815
+ dashFromStyle, styleWithDash, dashPatternFromStyle,
816
+ arrowFromStyle, styleWithArrow,
817
+ sideFromStyle, fractionFromStyle, styleWithSide, sideFromFraction,
818
+ edgeStyleValueFromStyle, isOrthogonalEdgeStyle, jettyFromStyle, styleWithJetty,
819
+ lineKindFromStyle, styleWithLineKind, curvedFromStyle,
820
+ avoidFromStyle, styleWithAvoid,
821
+ normalizePoint, normalizePoints, pointsEqual,
822
+ edgeTerminalId, edgeFreePoint,
823
+ normalizeDrawioDoc, snapDocGeometry,
824
+ }