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.
package/lib/mxfile.js ADDED
@@ -0,0 +1,1661 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ /**
3
+ * mxfile(`.drawio`)编解码 + **无损写回** —— **宿主半边**(要 node:zlib / node:crypto)。
4
+ *
5
+ * 载体决策(2026-09):`.drawio` 就是唯一真相,不再有 `.dshd.json` 这种自定义格式。
6
+ * 于是这个文件从"导入/导出的转换器"升级成"读写器",而**写**必须满足一条硬要求:
7
+ *
8
+ * **只改我们拥有的单元,其余原文一个字节都不动。**
9
+ *
10
+ * 为什么:drawio 的画布能力比我们大(多页、图层、分组层级、图片、HTML 标签、
11
+ * UserObject 自定义属性、旋转翻转、页面设置…)。我们只理解其中一个子集 ——
12
+ * 如果写回是"按模型重新生成整份 XML",那么用户稿子里我们不理解的部分会在保存时
13
+ * **被悄悄删掉**;而"悄悄"是最糟的失败方式(用户以为在编辑,其实在删)。
14
+ * 所以写回做成外科手术:
15
+ * · 页 1 的 `<root>` 之外(其他页、mxfile 属性、空白)**逐字节保留**;
16
+ * · 单元按 id 对应:我们导入过的(节点/边)只重写 `value`/`style`/几何/端点这几处属性,
17
+ * 连 `<object>` 包装上的自定义属性、`<mxRectangle as="alternateBounds">` 都原样留着;
18
+ * · 我们没导入的单元(两端都找不到落点的边等)原样留着,不因为我们不认识就删;
19
+ * · 只有"我们导入过、而模型里已经没有了"的单元才删 —— 那才是用户真的删了它;
20
+ * · 原本压缩的页体,写回后仍然压缩(保持 drawio 的形态,也保住别处的字节)。
21
+ *
22
+ * 结果是一条可断言的性质:**打开后原样保存,文件逐字节不变**(见 check-mxfile)。
23
+ *
24
+ * 另外两件必须落在文件里的事(载体是唯一真相,没有第二个地方可存):
25
+ * · `meta.pinned`(人手工摆过版面,AI 别再自动重排)→ 一个隐藏的 `<object>` 元数据单元;
26
+ * · `revision` → **不用存**,直接用文件内容的指纹(`contentHash`)。这样 drawio 或别的
27
+ * 编辑器改过文件,指纹自然就变了,乐观锁不会因为"属性被 drawio 丢掉"而假冲突。
28
+ *
29
+ * 格式依据(都在 drawio 仓库里核实过):
30
+ * · 压缩:`Graph.compress = pako.deflateRaw(encodeURIComponent(xml))` → `btoa`;
31
+ * 解压是逆过程(js/grapheditor/Graph.js 的 compress / decompress)。
32
+ * · 结构:`<mxfile><diagram><mxGraphModel><root>`;页面设置挂在 `mxGraphModel` 的属性上。
33
+ * · 单元:`<mxCell id/value/style/parent/source/target/vertex/edge>` + `<mxGeometry as="geometry">`。
34
+ * · 用户对象:`<object label="…" id="…">` —— id/label 在外层,自定义属性也挂外层
35
+ * (这正是 drawio 发明它来存任意属性的原因,所以我们的元数据也挂这种单元上)。
36
+ * · 容器子单元的 `<mxGeometry>` 坐标是**相对父级**的(读要累加,写要减回去)。
37
+ */
38
+ import { createHash } from 'node:crypto'
39
+ import { deflateRawSync, inflateRawSync, inflateSync } from 'node:zlib'
40
+ // style 的"真相"只有一份(内核):这里只借它做**语义比较**,
41
+ // 免得"文档层规范化过的 style 串"和"文件里原样的串"逐字不同,就把没动过的单元重写一遍。
42
+ import { DEFAULT_EDGE_STYLE, formatStyle } from './style-kernel.js'
43
+
44
+ /** 画布默认页尺寸(新建文件时写进 mxGraphModel,drawio 缺省与此一致:A4 竖版 × 100 dpi)。 */
45
+ const PAGE_W = 850
46
+ const PAGE_H = 1100
47
+
48
+ /**
49
+ * 元数据单元的 id。它没有 `vertex`/`edge`,所以不会被当成图形单元;
50
+ * 挂在图层下(不是 root 下),所以也不会被误认成图层。
51
+ */
52
+ export const META_ID = 'drawai-meta'
53
+
54
+ // ---- 文本与属性小工具(mxfile 的属性/文本足够用,不引入 XML 依赖)-----------
55
+
56
+ /** 解码 XML 实体(含数字实体)。*/
57
+ function decodeEntities(text) {
58
+ return String(text)
59
+ .replace(/&#x([0-9a-fA-F]+);/g, (m, hex) => String.fromCodePoint(parseInt(hex, 16)))
60
+ .replace(/&#(\d+);/g, (m, dec) => String.fromCodePoint(parseInt(dec, 10)))
61
+ .replace(/&lt;/g, '<')
62
+ .replace(/&gt;/g, '>')
63
+ .replace(/&quot;/g, '"')
64
+ .replace(/&apos;/g, "'")
65
+ .replace(/&amp;/g, '&')
66
+ }
67
+
68
+ /** 属性值转义(写回用)。*/
69
+ function escapeAttr(text) {
70
+ return String(text)
71
+ .replace(/&/g, '&amp;')
72
+ .replace(/</g, '&lt;')
73
+ .replace(/>/g, '&gt;')
74
+ .replace(/"/g, '&quot;')
75
+ }
76
+
77
+ /** 从一段"标签的属性区"里取一个属性的原值(未解码)。*/
78
+ function rawAttr(attrs, name) {
79
+ const match = new RegExp('\\b' + name + '\\s*=\\s*"([^"]*)"').exec(attrs)
80
+ return match === null ? undefined : match[1]
81
+ }
82
+
83
+ /** 取一个属性并解码实体。*/
84
+ function attr(attrs, name) {
85
+ const raw = rawAttr(attrs, name)
86
+ return raw === undefined ? undefined : decodeEntities(raw)
87
+ }
88
+
89
+ function num(value) {
90
+ const n = Number(value)
91
+ return Number.isFinite(n) ? n : undefined
92
+ }
93
+
94
+ /** 内容指纹:revision 就是它(不落盘、不依赖属性存活)。*/
95
+ export function contentHash(text) {
96
+ return createHash('sha256').update(String(text === undefined || text === null ? '' : text), 'utf8').digest('hex').slice(0, 12)
97
+ }
98
+
99
+ /**
100
+ * 属性区解析:把一段属性拆成有序的 {name, leadStart, start, end, value}。
101
+ * 保留顺序与原始空白,才能做到"没改的属性一个字节都不动"。
102
+ */
103
+ function parseAttrs(text, from, to) {
104
+ const out = []
105
+ const re = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)')/g
106
+ re.lastIndex = from
107
+ let match
108
+ while ((match = re.exec(text)) !== null) {
109
+ if (match.index >= to) break
110
+ let lead = match.index
111
+ while (lead > from && /\s/.test(text.charAt(lead - 1))) lead -= 1
112
+ out.push({
113
+ name: match[1],
114
+ leadStart: lead,
115
+ start: match.index,
116
+ end: match.index + match[0].length,
117
+ value: match[3] !== undefined ? match[3] : match[4],
118
+ })
119
+ }
120
+ return out
121
+ }
122
+
123
+ /**
124
+ * 在一段属性区里批量覆盖/追加/删除属性。**值必须是已转义的**,`null` 表示删掉这个键。
125
+ *
126
+ * 为什么要"批量":同一区域连续改两个属性时,第一个属性的长度变化会让第二个属性的
127
+ * 偏移失效 —— 实测过:`x="40"` 改成 `x="200"` 之后,`y` 被写进了标签中间,
128
+ * 整份文件当场花掉。所以这里一次解析、按偏移**从右往左**改。
129
+ */
130
+ function setAttrsIn(text, attrsStart, attrsEnd, pairs) {
131
+ const list = parseAttrs(text, attrsStart, attrsEnd)
132
+ const byName = {}
133
+ for (const entry of list) byName[entry.name] = entry
134
+ const edits = []
135
+ const missing = []
136
+ for (const name of Object.keys(pairs)) {
137
+ const value = pairs[name]
138
+ const found = byName[name]
139
+ if (value === null) {
140
+ if (found !== undefined) edits.push({ start: found.leadStart, end: found.end, text: '' })
141
+ continue
142
+ }
143
+ const rendered = name + '="' + value + '"'
144
+ if (found !== undefined) edits.push({ start: found.start, end: found.end, text: rendered })
145
+ else missing.push(rendered)
146
+ }
147
+ edits.sort((a, b) => b.start - a.start)
148
+ let out = text
149
+ // 追加放在**原位**(所有替换点之后),这样先追加也不会影响替换点的偏移。
150
+ if (missing.length > 0) {
151
+ const body = text.slice(attrsStart, attrsEnd)
152
+ const trimmed = body.replace(/\s+$/, '')
153
+ const at = attrsStart + trimmed.length
154
+ out = out.slice(0, at) + ' ' + missing.join(' ') + out.slice(at)
155
+ }
156
+ for (const edit of edits) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
157
+ return out
158
+ }
159
+
160
+ /**
161
+ * 在一个单元的原文上按区域批量改写。
162
+ *
163
+ * 两条规则缺一不可(都踩过):
164
+ * 1. **同一个区域的多处改动要合成一次**:`value` 与 `style` 都在 `<mxCell …>` 的属性区里,
165
+ * 分两次改的话,第一次的长度变化会让第二次的偏移失效,标签会被写进属性中间;
166
+ * 2. 不同区域**从右往左**处理:左侧偏移不会因为右侧长度变化而失效。
167
+ */
168
+ function withAttrChanges(text, changes) {
169
+ const merged = []
170
+ for (const change of changes) {
171
+ const same = merged.filter((m) => m.attrsStart === change.attrsStart && m.attrsEnd === change.attrsEnd)[0]
172
+ if (same === undefined) merged.push({ attrsStart: change.attrsStart, attrsEnd: change.attrsEnd, pairs: Object.assign({}, change.pairs) })
173
+ else Object.assign(same.pairs, change.pairs)
174
+ }
175
+ merged.sort((a, b) => b.attrsStart - a.attrsStart)
176
+ let out = text
177
+ for (const change of merged) out = setAttrsIn(out, change.attrsStart, change.attrsEnd, change.pairs)
178
+ return out
179
+ }
180
+
181
+ /** 从属性区里删掉一个属性(连它前面的空白一起删,免得留下双空格)。 */
182
+ function removeAttrIn(text, attrsStart, attrsEnd, name) {
183
+ return setAttrsIn(text, attrsStart, attrsEnd, { [name]: null })
184
+ }
185
+
186
+ /** 取属性区里某个属性的原值(未解码),没有就 undefined。 */
187
+ function attrIn(text, attrsStart, attrsEnd, name) {
188
+ const found = parseAttrs(text, attrsStart, attrsEnd).filter((a) => a.name === name)[0]
189
+ return found === undefined ? undefined : found.value
190
+ }
191
+
192
+ // ---- 标签文本 ---------------------------------------------------------------
193
+
194
+ /**
195
+ * 把 drawio 的标签文本变成"纯文本标签"。
196
+ *
197
+ * 我们按纯文本渲染标签,而 drawio 的标签常带 HTML(`html=1` + `<br>`、`<b>`…)。
198
+ * 所以把 `<br>` 折成空格、去掉其余标签,再解实体 —— 读起来才是人写的那句话。
199
+ * 代价:改过标签的单元写回时 markup 回不去(如实记在 notes 里)。
200
+ */
201
+ function labelFromValue(value) {
202
+ if (value === undefined || value === null) return ''
203
+ let text = String(value)
204
+ if (text.indexOf('<') < 0 && text.indexOf('&') < 0) return text
205
+ const hadMarkup = /<[a-zA-Z/][^>]*>/.test(text)
206
+ text = text.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, '')
207
+ text = decodeEntities(text)
208
+ return hadMarkup ? text.replace(/\s+/g, ' ').trim() : text
209
+ }
210
+
211
+ /** 这个标签文本里是否含 markup(用于 notes)。*/
212
+ function hasMarkup(value) {
213
+ return typeof value === 'string' && /<[a-zA-Z/][^>]*>/.test(value)
214
+ }
215
+
216
+ // ---- 页体:压缩 / 解压 ------------------------------------------------------
217
+
218
+ /** 解压一段 `<diagram>` 内容:未压缩直接返回,压缩的按 drawio 的算法解。 */
219
+ function decodeDiagramBody(body) {
220
+ const trimmed = String(body === undefined || body === null ? '' : body).trim()
221
+ if (trimmed.length === 0) return ''
222
+ if (trimmed.charAt(0) === '<') return trimmed
223
+ const buffer = Buffer.from(trimmed.replace(/\s+/g, ''), 'base64')
224
+ let xml
225
+ try {
226
+ xml = inflateRawSync(buffer).toString('utf8')
227
+ } catch (error) {
228
+ // 少数文件用的是带 zlib 头的 deflate;再试一次,仍失败就把原因抛给调用方。
229
+ xml = inflateSync(buffer).toString('utf8')
230
+ }
231
+ try {
232
+ return decodeURIComponent(xml)
233
+ } catch (error) {
234
+ return xml
235
+ }
236
+ }
237
+
238
+ /** 按 drawio 的算法压一段 mxGraphModel(`Graph.compress` 的等价物)。*/
239
+ function compressModel(modelXml) {
240
+ return deflateRawSync(Buffer.from(encodeURIComponent(modelXml), 'utf8')).toString('base64')
241
+ }
242
+
243
+ // ---- 扫描单元(带文本跨度,供写回做定点手术)--------------------------------
244
+
245
+ /**
246
+ * 扫描 `[from, to)` 范围内的单元,返回**带文本跨度**的记录。
247
+ *
248
+ * `start`/`end` 是相对整份文档的绝对偏移;每个单元内部的 `attrsSpan`/`geo`/`wrapper`
249
+ * 是**相对该单元起点**的偏移 —— 写回时先剪出单元原文,在它上面做定点替换,
250
+ * 就不必再关心全局偏移。
251
+ */
252
+ function scanCells(source, from, to) {
253
+ const cells = []
254
+ const re = /<(object|UserObject|mxCell)\b([^>]*?)(\/>|>)/g
255
+ re.lastIndex = from
256
+ let match
257
+ while ((match = re.exec(source)) !== null) {
258
+ if (match.index >= to) break
259
+ const kind = match[1]
260
+ const absStart = match.index
261
+ const outerSelfClosing = match[3] === '/>'
262
+ const outerAttrsStart = match.index + 1 + kind.length
263
+ const outerAttrsEnd = match.index + match[0].length - (outerSelfClosing ? 2 : 1)
264
+ let absEnd
265
+ const blockFrom = outerAttrsEnd + (outerSelfClosing ? 2 : 1)
266
+ let blockTo = blockFrom
267
+ if (outerSelfClosing) {
268
+ absEnd = outerAttrsEnd + 2
269
+ } else {
270
+ const close = source.indexOf('</' + kind + '>', blockFrom)
271
+ blockTo = close < 0 ? to : close
272
+ absEnd = close < 0 ? to : close + kind.length + 3
273
+ }
274
+
275
+ // 用户对象(<object>):id/label/自定义属性在外层,里面才是 mxCell。
276
+ let attrsStart = outerAttrsStart
277
+ let attrsEnd = outerAttrsEnd
278
+ let cellSelfClosing = outerSelfClosing
279
+ let innerFrom = blockFrom
280
+ let innerTo = blockTo
281
+ let wrapper = null
282
+ let wrapperAttrs = null
283
+ if (kind !== 'mxCell') {
284
+ wrapperAttrs = source.slice(outerAttrsStart, outerAttrsEnd)
285
+ const inner = /<mxCell\b([^>]*?)(\/>|>)/.exec(source.slice(blockFrom, blockTo))
286
+ if (inner !== null) {
287
+ const innerStart = blockFrom + inner.index
288
+ const innerSelf = inner[2] === '/>'
289
+ attrsStart = innerStart + '<mxCell'.length
290
+ attrsEnd = innerStart + inner[0].length - (innerSelf ? 2 : 1)
291
+ cellSelfClosing = innerSelf
292
+ innerFrom = attrsEnd + (innerSelf ? 2 : 1)
293
+ if (innerSelf) {
294
+ innerTo = innerFrom
295
+ } else {
296
+ const innerClose = source.indexOf('</mxCell>', innerFrom)
297
+ innerTo = innerClose < 0 ? blockTo : innerClose
298
+ }
299
+ }
300
+ wrapper = {
301
+ attrsStart: outerAttrsStart - absStart,
302
+ attrsEnd: outerAttrsEnd - absStart,
303
+ }
304
+ }
305
+
306
+ // 几何(在单元内容区里找)
307
+ let geometry = null
308
+ let geo = null
309
+ const content = source.slice(innerFrom, innerTo)
310
+ const geoMatch = /<mxGeometry\b([^>]*?)(\/>|>)/.exec(content)
311
+ if (geoMatch !== null) {
312
+ const geoAbsStart = innerFrom + geoMatch.index
313
+ const geoSelf = geoMatch[2] === '/>'
314
+ const geoAttrsStart = geoAbsStart + '<mxGeometry'.length
315
+ const geoAttrsEnd = innerFrom + geoMatch.index + geoMatch[0].length - (geoSelf ? 2 : 1)
316
+ const geoInnerFrom = geoAttrsEnd + (geoSelf ? 2 : 1)
317
+ let geoInnerTo = geoInnerFrom
318
+ let geoAbsEnd = geoAttrsEnd + 2
319
+ if (!geoSelf) {
320
+ const geoClose = source.indexOf('</mxGeometry>', geoInnerFrom)
321
+ geoInnerTo = geoClose < 0 ? innerTo : geoClose
322
+ geoAbsEnd = geoClose < 0 ? innerTo : geoClose + '</mxGeometry>'.length
323
+ }
324
+ const geoAttrsText = source.slice(geoAttrsStart, geoAttrsEnd)
325
+ const geoInnerText = source.slice(geoInnerFrom, geoInnerTo)
326
+
327
+ const points = []
328
+ const arrayMatch = /<Array\b[^>]*as\s*=\s*"points"[^>]*>([\s\S]*?)<\/Array>/.exec(geoInnerText)
329
+ if (arrayMatch !== null) {
330
+ const pointRe = /<mxPoint\b([^>]*?)\/>/g
331
+ let pm
332
+ while ((pm = pointRe.exec(arrayMatch[1])) !== null) {
333
+ const x = num(attr(pm[1], 'x'))
334
+ const y = num(attr(pm[1], 'y'))
335
+ if (x !== undefined && y !== undefined) points.push({ x: x, y: y })
336
+ }
337
+ }
338
+ const terminal = (name) => {
339
+ const re2 = new RegExp('<mxPoint\\b([^>]*?)as\\s*=\\s*"' + name + '"[^>]*?/>')
340
+ const found = re2.exec(geoInnerText)
341
+ if (found === null) return null
342
+ const x = num(attr(found[1], 'x'))
343
+ const y = num(attr(found[1], 'y'))
344
+ return x === undefined || y === undefined ? null : { x: x, y: y }
345
+ }
346
+ // `<mxPoint as="offset"/>`:drawio 存"标签相对于那个点的残余偏移"用的。
347
+ // 没有 x/y 属性时按 (0,0) 处理。
348
+ const offsetMatch = /<mxPoint\b([^>]*?)as\s*=\s*"offset"[^>]*?\/>/.exec(geoInnerText)
349
+ const offset =
350
+ offsetMatch === null
351
+ ? { x: 0, y: 0 }
352
+ : { x: num(attr(offsetMatch[1], 'x')) === undefined ? 0 : num(attr(offsetMatch[1], 'x')), y: num(attr(offsetMatch[1], 'y')) === undefined ? 0 : num(attr(offsetMatch[1], 'y')) }
353
+ geometry = {
354
+ x: num(attr(geoAttrsText, 'x')),
355
+ y: num(attr(geoAttrsText, 'y')),
356
+ width: num(attr(geoAttrsText, 'width')),
357
+ height: num(attr(geoAttrsText, 'height')),
358
+ relative: attr(geoAttrsText, 'relative') === '1',
359
+ points: points,
360
+ sourcePoint: terminal('sourcePoint'),
361
+ targetPoint: terminal('targetPoint'),
362
+ offset: offset,
363
+ }
364
+ geo = {
365
+ attrsStart: geoAttrsStart - absStart,
366
+ attrsEnd: geoAttrsEnd - absStart,
367
+ selfClosing: geoSelf,
368
+ start: geoAbsStart - absStart,
369
+ end: geoAbsEnd - absStart,
370
+ innerFrom: geoInnerFrom - absStart,
371
+ innerTo: geoInnerTo - absStart,
372
+ innerText: geoInnerText,
373
+ }
374
+ }
375
+
376
+ const cellAttrsText = source.slice(attrsStart, attrsEnd)
377
+ // `<object>`/`<UserObject>` 包装的单元:内层 mxCell **不带 id**,id 在外层。
378
+ // 漏了这条回退的后果实测过:已有的元数据单元被当成"不存在",于是每次保存都再追加一个,
379
+ // 文件里躺一串重复的 drawai-meta。
380
+ const innerId = attr(cellAttrsText, 'id')
381
+ const cellId = innerId === undefined && wrapperAttrs !== null ? attr(wrapperAttrs, 'id') : innerId
382
+ // 包装上的**自定义属性**(除了 id/label)就是 drawio 的"编辑数据"。
383
+ const data = {}
384
+ if (wrapperAttrs !== null) {
385
+ for (const entry of parseAttrs(wrapperAttrs, 0, wrapperAttrs.length)) {
386
+ if (entry.name === 'id' || entry.name === 'label') continue
387
+ data[entry.name] = decodeEntities(entry.value)
388
+ }
389
+ }
390
+ cells.push({
391
+ element: kind,
392
+ id: cellId,
393
+ label: wrapperAttrs !== null && attr(wrapperAttrs, 'label') !== undefined ? attr(wrapperAttrs, 'label') : attr(cellAttrsText, 'value'),
394
+ data: data,
395
+ style: rawAttr(cellAttrsText, 'style') === undefined ? '' : decodeEntities(rawAttr(cellAttrsText, 'style')),
396
+ parent: attr(cellAttrsText, 'parent'),
397
+ // 图层的两个状态位(drawio 就写在图层单元上):`visible="0"` = 隐藏、`locked="1"` = 锁定。
398
+ visible: attr(cellAttrsText, 'visible'),
399
+ locked: attr(cellAttrsText, 'locked'),
400
+ source: attr(cellAttrsText, 'source'),
401
+ target: attr(cellAttrsText, 'target'),
402
+ vertex: attr(cellAttrsText, 'vertex') === '1',
403
+ edge: attr(cellAttrsText, 'edge') === '1',
404
+ // drawio 的**边标签单元**:它是个 vertex,但既不是形状也不是容器 ——
405
+ // 样式里带 `edgeLabel`,几何是 `relative="1"`(相对它所属的连线/父级定位)。
406
+ // 当成普通节点读进来会出两件坏事:画布上多一个鬼影框(实测:用户那份 drawio 文件里
407
+ // 就有一个 `value="Text"` 的 edgeLabel 单元,在画布上显示成一个写着 Text 的蓝框);
408
+ // 一旦被拖动,写回会把**绝对坐标**写进一个"相对"几何里,把 drawio 里的标签偏移弄歪。
409
+ // 所以这类单元一律**不导入、不拥有、原样保留**(见 ownershipOf)。
410
+ labelCell:
411
+ (geometry !== null && geometry.relative === true) || /(^|;)edgeLabel[;=]/.test(rawAttr(cellAttrsText, 'style') === undefined ? '' : rawAttr(cellAttrsText, 'style')),
412
+ geometry: geometry,
413
+ // 文本跨度
414
+ start: absStart,
415
+ end: absEnd,
416
+ attrsSpan: { attrsStart: attrsStart - absStart, attrsEnd: attrsEnd - absStart, selfClosing: cellSelfClosing },
417
+ wrapper: wrapper,
418
+ wrapperAttrs: wrapperAttrs,
419
+ geo: geo,
420
+ })
421
+ re.lastIndex = absEnd
422
+ }
423
+ return cells
424
+ }
425
+
426
+ // ---- 分析:定位页 1、解出 mxGraphModel、拿到全部单元与文本跨度 ----------------
427
+
428
+ /**
429
+ * 全文分析。**解析与写回共用同一份结构** —— 两边对"哪些单元属于我们"必须完全一致,
430
+ * 否则写回会把我们不认识的单元当成"用户删掉的"删掉。
431
+ */
432
+ function analyzeMxfile(source) {
433
+ const diagrams = [...source.matchAll(/<diagram\b([^>]*?)(\/>|>)([\s\S]*?)(?:<\/diagram>|$)/g)]
434
+ if (diagrams.length === 0) throw new Error('这不是 mxfile:找不到 <diagram> 元素')
435
+ const first = diagrams[0]
436
+ const closed = first[0].endsWith('</diagram>')
437
+ const bodyEnd = first.index + first[0].length - (closed ? '</diagram>'.length : 0)
438
+ const bodyStart = bodyEnd - first[3].length
439
+ // 页体的**核心**(去掉两侧空白):写回时只替换这一段,
440
+ // 否则 trim 过的 XML 会把原文里的换行/缩进一起吃掉(实测过:整份文件被压成一行)。
441
+ const lead = /^\s*/.exec(first[3])[0]
442
+ const trail = /\s*$/.exec(first[3])[0]
443
+ const coreStart = bodyStart + lead.length
444
+ const coreEnd = bodyEnd - trail.length
445
+ const core = first[3].slice(lead.length, first[3].length - trail.length)
446
+ const compressed = core.charAt(0) !== '<'
447
+ const modelXml = decodeDiagramBody(core)
448
+ const modelMatch = /<mxGraphModel\b([^>]*?)(\/>|>)/.exec(modelXml)
449
+ if (modelMatch === null) throw new Error('这个 <diagram> 里没有 <mxGraphModel>')
450
+ const rootStart = modelXml.indexOf('<root>')
451
+ const rootEnd = modelXml.indexOf('</root>')
452
+ if (rootStart < 0 || rootEnd < rootStart) throw new Error('mxGraphModel 里没有 <root>')
453
+ const innerFrom = rootStart + '<root>'.length
454
+ const innerTo = rootEnd
455
+ const cells = scanCells(modelXml, innerFrom, innerTo)
456
+
457
+ const byId = new Map()
458
+ for (const cell of cells) if (typeof cell.id === 'string') byId.set(cell.id, cell)
459
+ const rootCell = cells.find((c) => c.parent === undefined || c.parent === null || c.parent === '')
460
+ const rootId = rootCell === undefined ? undefined : rootCell.id
461
+ // 图层:挂在 root 下、既不是顶点也不是边、也没有几何的单元。
462
+ // 元数据单元挂在图层下(parent 是图层而不是 root),所以不会被误判成图层。
463
+ const isLayer = (cell) =>
464
+ cell.id !== META_ID && cell.parent === rootId && cell.vertex !== true && cell.edge !== true && cell.geometry === null
465
+ const layerCells = cells.filter(isLayer)
466
+ const layerIds = new Set(layerCells.map((c) => c.id))
467
+ const layerOrder = layerCells.map((c) => c.id)
468
+ // 图层的三个属性就是 drawio 的写法:`value` = 名字、`visible="0"` = 隐藏、`locked="1"` = 锁定。
469
+ // 缺省(不写属性)就是"名字为空 / 可见 / 未锁"。属性在 scanCells 里已经读好了。
470
+ const layers = layerCells.map((cell) => ({
471
+ id: String(cell.id),
472
+ name: typeof cell.label === 'string' ? decodeEntities(cell.label) : '',
473
+ visible: cell.visible !== '0',
474
+ locked: cell.locked === '1',
475
+ }))
476
+
477
+ return {
478
+ source: source,
479
+ pageCount: diagrams.length,
480
+ // 纸张尺寸(drawio 挂在 `mxGraphModel` 上的属性):AI 做布局/导出建议时要看它 ——
481
+ // 850×1100 是 drawio 的 A4 竖版缺省,但不代表用户这份文件就是那个尺寸。
482
+ pageSize: readPageSize(modelXml),
483
+ bodyStart: coreStart,
484
+ bodyEnd: coreEnd,
485
+ compressed: compressed,
486
+ modelXml: modelXml,
487
+ rootStart: rootStart,
488
+ rootEnd: rootEnd,
489
+ innerFrom: innerFrom,
490
+ innerTo: innerTo,
491
+ cells: cells,
492
+ byId: byId,
493
+ rootId: rootId,
494
+ layerIds: layerIds,
495
+ layerOrder: layerOrder,
496
+ layers: layers,
497
+ isLayer: isLayer,
498
+ }
499
+ }
500
+
501
+ /**
502
+ * 从 `<mxGraphModel>` 的属性里读出纸张尺寸(`pageWidth`/`pageHeight`)。
503
+ *
504
+ * 读不出来(没写 / 不是数)就返回 null —— 那是"这份文件没说",不是 850×1100;
505
+ * 由调用方决定要不要退回缺省(画布新建时用的就是 drawio 的 A4 竖版缺省)。
506
+ */
507
+ function readPageSize(modelXml) {
508
+ const w = Number(rawAttr(modelXml, 'pageWidth'))
509
+ const h = Number(rawAttr(modelXml, 'pageHeight'))
510
+ if (Number.isFinite(w) === false || Number.isFinite(h) === false || w <= 0 || h <= 0) return null
511
+ return { w: w, h: h }
512
+ }
513
+
514
+ /**
515
+ * "哪些单元归我们管" —— 读与写必须用**同一套判据**。
516
+ * 顶点:所有非 root、非图层、非边标签的顶点(含容器与图片形状)。
517
+ * 边:两端至少各有一个真实落点或自由点(与 drawio 一致;两端都落空的边我们不认识,原样留着)。
518
+ *
519
+ * `labelCell`(drawio 的边标签单元)**一律不拥有** —— 不拥有才不会在"模型里没有它"时被当成
520
+ * 用户删除而抹掉(那是真正的数据丢失),也不会把绝对坐标写进它的相对几何里。
521
+ */
522
+ function ownershipOf(info) {
523
+ const nodeIds = new Set()
524
+ const owned = new Set()
525
+ for (const cell of info.cells) {
526
+ if (cell.vertex !== true) continue
527
+ if (cell.id === info.rootId || info.layerIds.has(cell.id) || cell.id === META_ID) continue
528
+ if (cell.labelCell === true) continue
529
+ if (typeof cell.id === 'string') {
530
+ nodeIds.add(cell.id)
531
+ owned.add(cell.id)
532
+ }
533
+ }
534
+ for (const cell of info.cells) {
535
+ if (cell.edge !== true) continue
536
+ const geo = cell.geometry
537
+ const hasSource = typeof cell.source === 'string' && nodeIds.has(cell.source)
538
+ const hasTarget = typeof cell.target === 'string' && nodeIds.has(cell.target)
539
+ const freeSource = geo !== null && geo.sourcePoint !== null && geo.sourcePoint !== undefined
540
+ const freeTarget = geo !== null && geo.targetPoint !== null && geo.targetPoint !== undefined
541
+ if (!hasSource && !freeSource) continue
542
+ if (!hasTarget && !freeTarget) continue
543
+ if (typeof cell.id === 'string') owned.add(cell.id)
544
+ }
545
+ return { owned: owned, nodeIds: nodeIds }
546
+ }
547
+
548
+ /** 父链的偏移量之和(不包含自己):容器子单元的坐标是相对父级的。**读**用这个。 */
549
+ function parentOffsetOf(info, cell) {
550
+ let x = 0
551
+ let y = 0
552
+ let parentId = cell.parent
553
+ const seen = new Set()
554
+ while (typeof parentId === 'string' && seen.has(parentId) === false) {
555
+ seen.add(parentId)
556
+ const parent = info.byId.get(parentId)
557
+ if (parent === undefined || info.layerIds.has(parent.id) || parent.id === info.rootId) break
558
+ x += parent.geometry !== null && parent.geometry.x !== undefined ? parent.geometry.x : 0
559
+ y += parent.geometry !== null && parent.geometry.y !== undefined ? parent.geometry.y : 0
560
+ parentId = parent.parent
561
+ }
562
+ return { x: x, y: y }
563
+ }
564
+
565
+ /**
566
+ * 从某个单元出发,沿**文件里**的父链找它属于哪一层(读时的 `layerOf` 是同一个意思)。
567
+ * 写回时用来判断"模型要求的层"和"文件里这一支的层"是不是同一层。
568
+ */
569
+ function layerOfParent(info, cellOrId) {
570
+ const first = typeof cellOrId === 'string' ? cellOrId : cellOrId === null || cellOrId === undefined ? undefined : cellOrId.id
571
+ let parentId = first
572
+ const seen = new Set()
573
+ while (typeof parentId === 'string' && seen.has(parentId) === false) {
574
+ seen.add(parentId)
575
+ if (info.layerIds.has(parentId)) return parentId
576
+ if (parentId === info.rootId) return null
577
+ const cell = info.byId.get(parentId)
578
+ if (cell === undefined) return null
579
+ parentId = cell.parent
580
+ }
581
+ return null
582
+ }
583
+
584
+ /**
585
+ * **写**回时,某个单元的"新父级 + 新父链偏移"。
586
+ *
587
+ * 与读时的 `parentOffsetOf` 有两处关键差别:
588
+ * 1. 画布是这次保存的真相 —— 父级自己可能也被移动了,子单元的相对坐标要按**新**的
589
+ * 父级位置反算(不然用户看到的画面和文件里存的对不上);
590
+ * 2. 父级可能**被删了**:那就往上接到最近一个还活着的祖先,并把被删祖先的那段偏移补回去。
591
+ * 漏了这条,文件里会留下指向不存在单元的 `parent`(drawio 打开会丢层级)。
592
+ *
593
+ * 第三条是图层(`wantLayer`):模型把单元放到了**另一个层**时,必须改 `parent` ——
594
+ * 这是"移到别的层"唯一能生效的地方(不改的话文件里它还挂在老层上,读回来 layer 照旧是老的)。
595
+ * 只认**已知的**层 id:不存在的层会写出悬空 parent,drawio 打开会丢层级。
596
+ * 注意"容器里的单元"这一支要照旧保住:模型里的 `layer` 对容器子单元来说只是"它属于哪一层"
597
+ * (读时沿父链得到的),并不表示它该脱离容器 —— 所以只有**层变了**才换父级。
598
+ *
599
+ * 返回的 offset 是"沿父链累加的相对坐标总量",于是写入值 = 绝对坐标 − offset。
600
+ */
601
+ function writeChainOf(info, modelAbs, deletedIds, cell, wantLayer, knownLayers) {
602
+ // ① 先按文件里的父链算出"本来要写到哪个父级、偏移多少"(容器层级靠这一步保住)。
603
+ let parentId = cell.parent
604
+ let offset = { x: 0, y: 0 }
605
+ const seen = new Set()
606
+ while (typeof parentId === 'string' && seen.has(parentId) === false) {
607
+ seen.add(parentId)
608
+ if (info.layerIds.has(parentId) || parentId === info.rootId) break
609
+ const abs = modelAbs.get(parentId)
610
+ // 活下来的祖先:它的相对坐标已经按"绝对 − 它自己的父链"写过,所以整条链的偏移就等于它的绝对坐标。
611
+ if (abs !== undefined) {
612
+ offset = abs
613
+ break
614
+ }
615
+ const parent = info.byId.get(parentId)
616
+ if (parent === undefined) {
617
+ parentId = layerOrRoot(info)
618
+ offset = { x: 0, y: 0 }
619
+ break
620
+ }
621
+ parentId = parent.parent
622
+ }
623
+ if (typeof parentId !== 'string') {
624
+ parentId = layerOrRoot(info)
625
+ offset = { x: 0, y: 0 }
626
+ }
627
+ // ② 模型显式要求的层与文件这一支的层不一致 → 换父级(单元被显式移层时它离开原容器)。
628
+ const want = typeof wantLayer === 'string' && wantLayer.length > 0 && knownLayers.has(wantLayer) ? wantLayer : null
629
+ if (want !== null && layerOfParent(info, parentId) !== want) return { parentId: want, offset: { x: 0, y: 0 } }
630
+ return { parentId: parentId, offset: offset }
631
+ }
632
+
633
+ function layerOrRoot(info) {
634
+ return info.layerOrder.length > 0 ? info.layerOrder[0] : info.rootId
635
+ }
636
+
637
+ /**
638
+ * 一个单元属于哪个图层:沿 parent 往上找**第一个图层**。
639
+ *
640
+ * 挂在边上的边标签单元 parent 是那条边,所以它会拿到边所在的那一层 ——
641
+ * 于是"隐藏某一层"能把那一层里的标签一起藏掉(否则会剩下一堆孤零零的字)。
642
+ *
643
+ * @returns 图层 id,或 null(走到 root 也没找到,理论上不该发生)
644
+ */
645
+ function layerIdOf(info, cell) {
646
+ let parentId = cell === null || cell === undefined ? undefined : cell.parent
647
+ const seen = new Set()
648
+ while (typeof parentId === 'string' && seen.has(parentId) === false) {
649
+ seen.add(parentId)
650
+ if (info.layerIds.has(parentId)) return parentId
651
+ if (parentId === info.rootId) return null
652
+ const parent = info.byId.get(parentId)
653
+ if (parent === undefined) return null
654
+ parentId = parent.parent
655
+ }
656
+ return null
657
+ }
658
+
659
+ // ---- 解析 → 语义文档 -------------------------------------------------------
660
+
661
+ /**
662
+ * 文件里那条边几何上的**标签位置**(drawio 的 `mxGeometry x/y` + `<mxPoint as="offset">`)。
663
+ *
664
+ * 只有 `relative="1"` 是这个语义:`x` 是沿边比例(-1..1,0 = 中点)、`y` 是垂直偏移 px、
665
+ * `offset` 是"取整后剩下的零头"。非 relative 的那一支是另一套算法(相对两端连线的中点),
666
+ * 我们**不认**这种位置 —— 返回 null 表示"这块不归我们动",写回时原样保留。
667
+ *
668
+ * `present`:文件里到底写没写 x/y。没有(= 从没拖过,就是中点)与"拖回过中点"在语义上一样,
669
+ * 但字节上不同,所以写回时要能区分。
670
+ */
671
+ function relativeLabelPos(geo) {
672
+ if (geo === null || geo === undefined || geo.relative !== true) return null
673
+ const x = Number(geo.x)
674
+ const y = Number(geo.y)
675
+ const off = geo.offset === null || geo.offset === undefined ? null : geo.offset
676
+ const offsetX = off === null ? 0 : Number(off.x) === Number(off.x) ? Number(off.x) : 0
677
+ const offsetY = off === null ? 0 : Number(off.y) === Number(off.y) ? Number(off.y) : 0
678
+ if (Number.isFinite(x) === false || Number.isFinite(y) === false) {
679
+ return { x: 0, y: 0, offsetX: offsetX, offsetY: offsetY, present: false }
680
+ }
681
+ return { x: x, y: y, offsetX: offsetX, offsetY: offsetY, present: true }
682
+ }
683
+
684
+ /** 模型里那条边声明的标签位置;没声明(从没拖过)返回 null = 用弧长中点。 */
685
+ function modelLabelPos(edge) {
686
+ const x = Number(edge.labelX)
687
+ const y = Number(edge.labelY)
688
+ if (Number.isFinite(x) === false || Number.isFinite(y) === false) return null
689
+ const offsetX = Number(edge.labelOffsetX)
690
+ const offsetY = Number(edge.labelOffsetY)
691
+ return { x: x, y: y, offsetX: Number.isFinite(offsetX) ? offsetX : 0, offsetY: Number.isFinite(offsetY) ? offsetY : 0 }
692
+ }
693
+
694
+ /** 位置要不要动:模型里有就按模型写;模型里没有但文件里有(用户把它归中了)就删掉。 */
695
+ function labelPosChanged(filePos, wantPos) {
696
+ if (filePos === null) return false
697
+ if (wantPos === null) return filePos.present === true
698
+ if (filePos.present === false) return true
699
+ return wantPos.x !== filePos.x || wantPos.y !== filePos.y || wantPos.offsetX !== filePos.offsetX || wantPos.offsetY !== filePos.offsetY
700
+ }
701
+
702
+ /** `<mxPoint as="offset">` 的值:偏移为 0 就不写这个元素(drawio 缺省也是 0)。 */
703
+ function labelOffsetPoint(pos) {
704
+ if (pos === null) return null
705
+ if (pos.offsetX === 0 && pos.offsetY === 0) return null
706
+ return { x: pos.offsetX, y: pos.offsetY }
707
+ }
708
+
709
+ /**
710
+ * 解析 mxfile 文本 → 语义文档。
711
+ *
712
+ * @param text `.drawio` 全文
713
+ * @param options.existing 可选的现有文档(用来保住 meta.pinned)
714
+ * @returns { doc, notes, pageCount } —— notes 是给人看的损失说明(多页、分组…)
715
+ */
716
+ export function parseMxfile(text, options) {
717
+ const notes = []
718
+ let source = String(text === undefined || text === null ? '' : text)
719
+ // 有的编辑器(记事本、某些 IDE 插件)会给 UTF-8 文件加 BOM。摘掉它更省心 ——
720
+ // 虽然"按内容找 <diagram>"本来就不受影响,但留着它会让文件首字符变成 U+FEFF。
721
+ if (source.charCodeAt(0) === 0xfeff) source = source.slice(1)
722
+ // UTF-16(记事本的"Unicode")被当 UTF-8 读进来时,ASCII 之间全是空字符。
723
+ // 这种情况**不猜**:硬按 UTF-16 重解释,中日韩标签会变成乱码,而乱码是看不见的损失。
724
+ // 宁可报一句用户马上能照做的错。
725
+ if (source.indexOf('\u0000') >= 0) {
726
+ throw new Error('这个文件像是 UTF-16 编码(读进来时每两个字节夹着一个空字符):请用 UTF-8 另存后再打开')
727
+ }
728
+ if (source.trim().length === 0) throw new Error('.drawio 文件是空的')
729
+
730
+ const info = analyzeMxfile(source)
731
+ const owned = ownershipOf(info)
732
+ const nodeIds = owned.nodeIds
733
+ const pageCount = info.pageCount
734
+
735
+ if (info.layerIds.size > 1) {
736
+ notes.push('这个文件有 ' + info.layerIds.size + ' 个图层:画布按「图层」菜单里的显示/隐藏来画;保存时图层结构照旧保留')
737
+ }
738
+
739
+ const nodes = []
740
+ let containers = 0
741
+ let imageShapes = 0
742
+ let markupLabels = 0
743
+ let labelCells = 0
744
+ for (const cell of info.cells) {
745
+ if (cell.vertex === true && cell.labelCell === true) labelCells += 1
746
+ if (cell.vertex !== true || owned.owned.has(String(cell.id)) === false) continue
747
+ const isContainer = info.cells.some((c) => c.parent === cell.id && c !== cell)
748
+ if (isContainer) containers += 1
749
+ if (typeof cell.style === 'string' && /(^|;)image[;=]/.test(cell.style)) imageShapes += 1
750
+ if (hasMarkup(cell.label)) markupLabels += 1
751
+ const offset = parentOffsetOf(info, cell)
752
+ const geo = cell.geometry
753
+ const node = {
754
+ id: String(cell.id),
755
+ label: labelFromValue(cell.label),
756
+ style: cell.style === undefined ? '' : String(cell.style),
757
+ x: (geo !== null && geo.x !== undefined ? geo.x : 0) + offset.x,
758
+ y: (geo !== null && geo.y !== undefined ? geo.y : 0) + offset.y,
759
+ w: geo !== null && geo.width !== undefined ? geo.width : 120,
760
+ h: geo !== null && geo.height !== undefined ? geo.height : 60,
761
+ }
762
+ if (cell.data !== undefined && cell.data !== null && Object.keys(cell.data).length > 0) node.data = cell.data
763
+ // 属于哪个图层(沿 parent 往上找第一个图层):渲染时隐藏层要整层不画。
764
+ const layerId = layerIdOf(info, cell)
765
+ if (layerId !== null) node.layer = layerId
766
+ // **容器父级**:父级不是图层/root 的,说明它挂在某个容器(或别的单元)里。
767
+ //
768
+ // 为什么要报出来:画布按**绝对位置**显示(层级在文件里保留),于是"移动容器"在画布上
769
+ // 并不会带走子单元,删除容器则会把子单元接到还活着的祖先上 —— AI 不知道这层关系时,
770
+ // 这两件事都是"看起来对、其实把组弄坏了"。给一个 parent 字段,它至少能看见
771
+ // "这个单元在哪个容器里",要一起动就把它们一起写进 move/duplicate 的 ids。
772
+ // 注意它只是**只读信息**:写回时父级由文件里的父链 + layer 决定(新单元一律挂在层下,
773
+ // 不往容器里塞 —— 那需要写相对坐标,而新单元写的是绝对坐标)。
774
+ if (typeof cell.parent === 'string' && cell.parent !== layerId && info.layerIds.has(cell.parent) === false && cell.parent !== info.rootId) {
775
+ node.parent = String(cell.parent)
776
+ }
777
+ nodes.push(node)
778
+ }
779
+
780
+ const edges = []
781
+ let dangling = 0
782
+ let kept = 0
783
+ for (const cell of info.cells) {
784
+ if (cell.edge !== true) continue
785
+ if (owned.owned.has(String(cell.id)) === false) {
786
+ kept += 1
787
+ continue
788
+ }
789
+ const geo = cell.geometry === null ? {} : cell.geometry
790
+ const hasSource = typeof cell.source === 'string' && nodeIds.has(cell.source)
791
+ const hasTarget = typeof cell.target === 'string' && nodeIds.has(cell.target)
792
+ const freeSource = geo.sourcePoint === undefined || geo.sourcePoint === null ? null : geo.sourcePoint
793
+ const freeTarget = geo.targetPoint === undefined || geo.targetPoint === null ? null : geo.targetPoint
794
+ if (!hasSource || !hasTarget) dangling += 1
795
+ const edge = { id: String(cell.id), style: cell.style === undefined || cell.style === '' ? '' : String(cell.style) }
796
+ if (hasSource) edge.from = cell.source
797
+ if (hasTarget) edge.to = cell.target
798
+ if (!hasSource && freeSource !== null) edge.sourcePoint = freeSource
799
+ if (!hasTarget && freeTarget !== null) edge.targetPoint = freeTarget
800
+ const label = labelFromValue(cell.label)
801
+ if (label.length > 0) edge.label = label
802
+ if (cell.data !== undefined && cell.data !== null && Object.keys(cell.data).length > 0) edge.data = cell.data
803
+ if (hasMarkup(cell.label)) markupLabels += 1
804
+ const points = geo.points === undefined ? [] : geo.points
805
+ if (points.length > 0) edge.points = points
806
+ // drawio 把"拖过的边标签位置"也存在**边自己的 mxGeometry** 上(`mxEdgeHandler.moveLabel`):
807
+ // x = 沿边比例(-1..1,0 = 中点)、y = 垂直偏移 px、offset = 残余偏移 px(取整后剩下的零头)。
808
+ // 只有 `relative="1"` 是这个语义(非 relative 的那支是"相对两端连线的中点",另一套算法),
809
+ // 所以只认 relative,其余一律原样留在文件里、不当成位置。
810
+ const labelPos = relativeLabelPos(geo)
811
+ if (labelPos !== null && labelPos.present === true) {
812
+ edge.labelX = labelPos.x
813
+ edge.labelY = labelPos.y
814
+ if (labelPos.offsetX !== 0 || labelPos.offsetY !== 0) {
815
+ edge.labelOffsetX = labelPos.offsetX
816
+ edge.labelOffsetY = labelPos.offsetY
817
+ }
818
+ }
819
+ const edgeLayer = layerIdOf(info, cell)
820
+ if (edgeLayer !== null) edge.layer = edgeLayer
821
+ edges.push(edge)
822
+ }
823
+
824
+ if (pageCount > 1) notes.push('这个文件有 ' + pageCount + ' 页,画布只显示/编辑第 1 页(其余页保存时原样保留)')
825
+ if (containers > 0) notes.push(containers + ' 个分组/容器:画布按绝对位置显示,文件里的父子层级照旧保留')
826
+ if (imageShapes > 0) notes.push(imageShapes + ' 个图片/自定义形状按矩形显示(本画布不画图片,单元原样保留)')
827
+ if (markupLabels > 0) notes.push(markupLabels + ' 个 HTML 标签按纯文本显示(改标签会写成纯文本)')
828
+ if (dangling > 0) notes.push(dangling + ' 条边是悬空端(用 sourcePoint/targetPoint 保留)')
829
+ if (kept > 0) notes.push(kept + ' 条边的两端都找不到落点:不显示,但原样保留在文件里')
830
+
831
+ // drawio 的**独立边标签单元**:按它自己的几何画出来(只读 —— 画布不改它们,保存原样带回)。
832
+ // 位置语义与 `mxGraphView.getPoint` 一致:挂在边上的(parent 是那条边)用
833
+ // x = 沿边比例(0 中点、±1 两端)、y = 垂直偏移、offset = 残余偏移;
834
+ // 没挂在边上的(例如从别处粘过来、parent 是图层)就按它的绝对坐标显示。
835
+ const labels = []
836
+ for (const cell of info.cells) {
837
+ if (cell.vertex !== true || cell.labelCell !== true) continue
838
+ const parentCell = typeof cell.parent === 'string' ? info.byId.get(cell.parent) : undefined
839
+ const onEdge = parentCell !== undefined && parentCell.edge === true
840
+ const geo = cell.geometry === null ? {} : cell.geometry
841
+ const offset = geo.offset === undefined || geo.offset === null ? { x: 0, y: 0 } : geo.offset
842
+ const labelItem = {
843
+ id: String(cell.id),
844
+ text: labelFromValue(cell.label),
845
+ edgeId: onEdge ? String(cell.parent) : null,
846
+ x: geo.x === undefined ? 0 : geo.x,
847
+ y: geo.y === undefined ? 0 : geo.y,
848
+ offsetX: offset.x,
849
+ offsetY: offset.y,
850
+ relative: geo.relative === true,
851
+ style: cell.style === undefined ? '' : String(cell.style),
852
+ }
853
+ // 挂在边上的:它的层就是那条边的层(parent 是边,不是图层)。
854
+ const labelLayer = layerIdOf(info, cell)
855
+ if (labelLayer !== null) labelItem.layer = labelLayer
856
+ labels.push(labelItem)
857
+ }
858
+ if (labels.length > 0) {
859
+ const onEdges = labels.filter((l) => l.edgeId !== null).length
860
+ notes.push(
861
+ labels.length + ' 个边标签单元(drawio 的 edgeLabel)按原样显示(只读,保存时原样保留)' +
862
+ (onEdges < labels.length ? ':其中 ' + (labels.length - onEdges) + ' 个没挂在任何边上,按它自己的坐标显示' : ''),
863
+ )
864
+ }
865
+
866
+ const existing = options !== undefined && options.existing !== undefined && options.existing !== null ? options.existing : null
867
+ const metaCell = info.byId.get(META_ID)
868
+ const metaAttrs =
869
+ metaCell === undefined
870
+ ? undefined
871
+ : metaCell.wrapperAttrs !== null
872
+ ? metaCell.wrapperAttrs
873
+ : info.modelXml.slice(metaCell.start + metaCell.attrsSpan.attrsStart, metaCell.start + metaCell.attrsSpan.attrsEnd)
874
+ const pinned = metaCell !== undefined && attr(metaAttrs, 'drawaiPinned') === '1'
875
+ const doc = {
876
+ version: 2,
877
+ revision: contentHash(source),
878
+ meta: { pinned: pinned === true },
879
+ nodes: nodes,
880
+ edges: edges,
881
+ labels: labels,
882
+ // 图层(drawio 的写法:挂在 root 下、没有 vertex/edge 的单元):
883
+ // id + 名字 + 显示/隐藏 + 锁定,顺序就是覆盖顺序(后面的压前面的)。
884
+ layers: info.layers,
885
+ }
886
+ // 纸张尺寸:文件里写了才带(不写就是"这份文件没说",不是 850×1100)。
887
+ if (info.pageSize !== null) doc.page = { w: info.pageSize.w, h: info.pageSize.h }
888
+ if (existing !== null && existing.meta !== undefined && existing.meta !== null && existing.meta.pinned === true) {
889
+ doc.meta.pinned = true
890
+ }
891
+ return { doc: doc, notes: notes, pageCount: pageCount }
892
+ }
893
+
894
+ // ---- 单元的 XML 生成(新单元、以及从零生成一份文件时共用)--------------------
895
+
896
+ /**
897
+ * 有数据(drawio 的"编辑数据")时包一层 `<object>`。
898
+ *
899
+ * 为什么必须包:mxCodec 只解 mxCell 认识的字段,写在 mxCell 上的陌生属性会被 drawio
900
+ * 重新保存时丢掉;`<object>`/`<UserObject>` 上的自定义属性才活得下来(drawio 发明它的原因)。
901
+ * label 也随之移到外层 —— 与 drawio 的形态一致。
902
+ */
903
+ function withDataWrapper(lines, item, indent) {
904
+ const data = item.data !== undefined && item.data !== null && typeof item.data === 'object' ? dataPairs(item.data) : null
905
+ if (data === null || Object.keys(data).length === 0) return lines.join('\n')
906
+ const first = lines[0]
907
+ // 内层那行去掉 value(label 已经在包装上),并整体再缩进一级
908
+ const inner = indent + ' ' + first.slice(indent.length).replace(/ value="[^"]*"/, '')
909
+ const rest = lines.slice(1).map((line) => indent + ' ' + line.slice(indent.length))
910
+ let attrs = ''
911
+ for (const key of Object.keys(data)) attrs += ' ' + key + '="' + data[key] + '"'
912
+ const label = typeof item.label === 'string' && item.label.length > 0 ? escapeAttr(item.label) : ''
913
+ return [indent + '<object label="' + label + '"' + attrs + ' id="' + escapeAttr(String(item.id)) + '">', inner]
914
+ .concat(rest)
915
+ .concat([indent + '</object>'])
916
+ .join('\n')
917
+ }
918
+
919
+ function nodeCellXml(node, parentId, indent) {
920
+ const style = typeof node.style === 'string' && node.style.length > 0 ? ' style="' + escapeAttr(node.style) + '"' : ''
921
+ const label = typeof node.label === 'string' && node.label.length > 0 ? ' value="' + escapeAttr(node.label) + '"' : ''
922
+ const x = Number.isFinite(Number(node.x)) ? Number(node.x) : 0
923
+ const y = Number.isFinite(Number(node.y)) ? Number(node.y) : 0
924
+ const w = Number.isFinite(Number(node.w)) ? Number(node.w) : 120
925
+ const h = Number.isFinite(Number(node.h)) ? Number(node.h) : 60
926
+ return withDataWrapper(
927
+ [
928
+ indent + '<mxCell id="' + escapeAttr(String(node.id)) + '"' + label + style + ' vertex="1" parent="' + escapeAttr(parentId) + '">',
929
+ indent + ' <mxGeometry x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" as="geometry" />',
930
+ indent + '</mxCell>',
931
+ ],
932
+ node,
933
+ indent,
934
+ )
935
+ }
936
+
937
+ /** 边的几何内部(自由端点 + 折点 + 标签残余偏移)。缩进按 indent 缩进一级。 */
938
+ function edgeInnerXml(edge, indent) {
939
+ const points = Array.isArray(edge.points) ? edge.points : []
940
+ const sourcePoint = edge.sourcePoint !== undefined && edge.sourcePoint !== null ? edge.sourcePoint : null
941
+ const targetPoint = edge.targetPoint !== undefined && edge.targetPoint !== null ? edge.targetPoint : null
942
+ const offset = edge.offset === undefined || edge.offset === null ? null : edge.offset
943
+ if (points.length === 0 && sourcePoint === null && targetPoint === null && offset === null) return ''
944
+ const lines = []
945
+ const inner = indent + ' '
946
+ if (sourcePoint !== null) lines.push(inner + '<mxPoint x="' + Number(sourcePoint.x) + '" y="' + Number(sourcePoint.y) + '" as="sourcePoint" />')
947
+ if (targetPoint !== null) lines.push(inner + '<mxPoint x="' + Number(targetPoint.x) + '" y="' + Number(targetPoint.y) + '" as="targetPoint" />')
948
+ if (points.length > 0) {
949
+ lines.push(inner + '<Array as="points">')
950
+ for (const point of points) lines.push(inner + ' <mxPoint x="' + Number(point.x) + '" y="' + Number(point.y) + '" />')
951
+ lines.push(inner + '</Array>')
952
+ }
953
+ if (offset !== null) lines.push(inner + '<mxPoint x="' + Number(offset.x) + '" y="' + Number(offset.y) + '" as="offset" />')
954
+ return '\n' + lines.join('\n') + '\n' + indent
955
+ }
956
+
957
+ /** 边几何的 x/y 属性(drawio 拿它存标签位置;没拖过就不写)。 */
958
+ function labelPosAttrs(pos) {
959
+ return pos === null ? '' : ' x="' + Number(pos.x) + '" y="' + Number(pos.y) + '"'
960
+ }
961
+
962
+ function edgeCellXml(edge, parentId, indent) {
963
+ const style = typeof edge.style === 'string' && edge.style.length > 0 ? ' style="' + escapeAttr(edge.style) + '"' : ''
964
+ const label = typeof edge.label === 'string' && edge.label.length > 0 ? ' value="' + escapeAttr(edge.label) + '"' : ''
965
+ const from = typeof edge.from === 'string' ? ' source="' + escapeAttr(edge.from) + '"' : ''
966
+ const to = typeof edge.to === 'string' ? ' target="' + escapeAttr(edge.to) + '"' : ''
967
+ const pos = modelLabelPos(edge)
968
+ const inner = edgeInnerXml(Object.assign({}, edge, { offset: labelOffsetPoint(pos) }), indent)
969
+ const head = indent + '<mxCell id="' + escapeAttr(String(edge.id)) + '"' + label + style + ' edge="1" parent="' + escapeAttr(parentId) + '"' + from + to + '>'
970
+ const geoHead = indent + ' <mxGeometry' + labelPosAttrs(pos) + ' relative="1" as="geometry"'
971
+ if (inner.length === 0) {
972
+ return withDataWrapper([head, geoHead + ' />', indent + '</mxCell>'], edge, indent)
973
+ }
974
+ return withDataWrapper([head, geoHead + '>' + inner + '</mxGeometry>', indent + '</mxCell>'], edge, indent)
975
+ }
976
+
977
+ /**
978
+ * 图层单元(drawio 的写法):挂在 root 下、**没有** vertex/edge,所以它既不是形状也不是连线。
979
+ * `value` = 名字、`visible="0"` = 隐藏、`locked="1"` = 锁定 —— 缺省(可见/未锁)不写属性。
980
+ */
981
+ function layerCellXml(id, name, visible, locked, indent) {
982
+ const attrs = ['id="' + escapeAttr(id) + '"']
983
+ if (name.length > 0) attrs.push('value="' + escapeAttr(name) + '"')
984
+ if (visible === false) attrs.push('visible="0"')
985
+ if (locked === true) attrs.push('locked="1"')
986
+ attrs.push('parent="0"')
987
+ return indent + '<mxCell ' + attrs.join(' ') + ' />'
988
+ }
989
+
990
+ /** 元数据单元:没有 vertex/edge,所以不会显示,也不会被我们当成图形单元。 */function metaCellXml(parentId, indent, meta) {
991
+ const pinned = meta !== undefined && meta !== null && meta.pinned === true ? '1' : '0'
992
+ return [
993
+ indent + '<object label="" drawaiMeta="1" drawaiPinned="' + pinned + '" id="' + META_ID + '">',
994
+ indent + ' <mxCell parent="' + escapeAttr(parentId) + '" />',
995
+ indent + '</object>',
996
+ ].join('\n')
997
+ }
998
+
999
+ // ---- 写回:在原文件上做定点手术 ---------------------------------------------
1000
+
1001
+ function spliceEdits(text, edits) {
1002
+ const sorted = edits.slice().sort((a, b) => b.start - a.start || b.end - a.end)
1003
+ let out = text
1004
+ for (const edit of sorted) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
1005
+ return out
1006
+ }
1007
+
1008
+ function pointsEqual(a, b) {
1009
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
1010
+ if (a.length !== b.length) return false
1011
+ for (let i = 0; i < a.length; i += 1) {
1012
+ if (Number(a[i].x) !== Number(b[i].x) || Number(a[i].y) !== Number(b[i].y)) return false
1013
+ }
1014
+ return true
1015
+ }
1016
+
1017
+ function samePoint(a, b) {
1018
+ const aNull = a === undefined || a === null
1019
+ const bNull = b === undefined || b === null
1020
+ if (aNull && bNull) return true
1021
+ if (aNull !== bNull) return false
1022
+ return Number(a.x) === Number(b.x) && Number(a.y) === Number(b.y)
1023
+ }
1024
+
1025
+ /**
1026
+ * style 是否等价 —— 写回时"没变就别动"的判据。
1027
+ *
1028
+ * 不能逐字比:文档层会把 style 规范化(键序、结尾分号),于是一个**没被碰过**的单元
1029
+ * 也会因为"串长得不一样"被重写,逐字节不变的性质当场破掉(实测踩过)。
1030
+ * 也不能只做语义比较:文件里没写 style 的连线 = drawio 的默认连线样式,
1031
+ * 文档里显式写着默认值同样算没变。
1032
+ */
1033
+ function stylesEqual(fileStyle, docStyle, isEdge) {
1034
+ const a = typeof fileStyle === 'string' ? fileStyle : ''
1035
+ const b = typeof docStyle === 'string' ? docStyle : ''
1036
+ if (a === b) return true
1037
+ const na = formatStyle(a)
1038
+ const nb = formatStyle(b)
1039
+ if (na === nb) return true
1040
+ if (isEdge === true && na === '' && nb === formatStyle(DEFAULT_EDGE_STYLE)) return true
1041
+ return false
1042
+ }
1043
+
1044
+ /** 给一个还没有几何的单元补上几何(自闭合的单元要展开成开闭标签对)。 */
1045
+ function appendGeometry(text, cell, geometryXml) {
1046
+ const attrsEnd = cell.attrsSpan.attrsEnd
1047
+ if (cell.attrsSpan.selfClosing) {
1048
+ // <mxCell … /> → <mxCell …>geometry</mxCell>
1049
+ return text.slice(0, attrsEnd) + '>' + geometryXml + '</mxCell>' + text.slice(attrsEnd + 2)
1050
+ }
1051
+ return text.slice(0, attrsEnd + 1) + geometryXml + text.slice(attrsEnd + 1)
1052
+ }
1053
+
1054
+ /**
1055
+ * 重新定位单个单元的文本跨度。属性改写会让长度变化,需要**重新定位**再动结构
1056
+ * (补几何 / 换几何内部)—— 拿旧偏移去做插入必然写错地方。
1057
+ */
1058
+ function rescanCell(text) {
1059
+ const cells = scanCells(text, 0, text.length)
1060
+ return cells.length > 0 ? cells[0] : null
1061
+ }
1062
+
1063
+ /** 数据对象 → 一组属性(转义后),id/label 不算数据。 */
1064
+ function dataPairs(data) {
1065
+ const pairs = {}
1066
+ for (const key of Object.keys(data)) {
1067
+ if (key === 'id' || key === 'label') continue
1068
+ pairs[key] = escapeAttr(String(data[key]))
1069
+ }
1070
+ return pairs
1071
+ }
1072
+
1073
+ /**
1074
+ * drawio 的"编辑数据"要落在 **`<object>` 包装**上。
1075
+ *
1076
+ * 为什么不能写在 mxCell 上:mxCodec 只解它认识的字段(id/value/style/parent/vertex/edge/
1077
+ * source/target/geometry),写在 mxCell 上的陌生属性**被 drawio 重新保存时直接丢掉**。
1078
+ * 这正是 drawio 发明 `<object>`/`<UserObject>` 包装的原因 —— 它的自定义属性才活得下来。
1079
+ */
1080
+ function dataChangeFor(cell, data) {
1081
+ if (cell.wrapper === null) return null
1082
+ const span = cell.wrapper
1083
+ const pairs = dataPairs(data)
1084
+ // 模型里已经去掉的键要从包装上删掉(不动 id/label)
1085
+ if (cell.wrapperAttrs !== null) {
1086
+ for (const entry of parseAttrs(cell.wrapperAttrs, 0, cell.wrapperAttrs.length)) {
1087
+ if (entry.name === 'id' || entry.name === 'label') continue
1088
+ if (Object.prototype.hasOwnProperty.call(pairs, entry.name) === false) pairs[entry.name] = null
1089
+ }
1090
+ }
1091
+ return { attrsStart: span.attrsStart, attrsEnd: span.attrsEnd, pairs: pairs }
1092
+ }
1093
+
1094
+ /** 把裸 mxCell 包成 `<object>`(label 移到包装上、并带上数据),返回单元原文。 */
1095
+ function wrapAsObject(cell, text, data) {
1096
+ const inner = setAttrsIn(text, cell.attrsSpan.attrsStart, cell.attrsSpan.attrsEnd, { value: null })
1097
+ let attrs = ''
1098
+ const pairs = dataPairs(data)
1099
+ for (const key of Object.keys(pairs)) attrs += ' ' + key + '="' + pairs[key] + '"'
1100
+ return '<object label="' + escapeAttr(labelFromValue(cell.label)) + '"' + attrs + ' id="' + escapeAttr(String(cell.id)) + '">' + inner + '</object>'
1101
+ }
1102
+
1103
+ /** 改写一个顶点单元:标签、style、几何(几何要写回**相对父级**的坐标)。 */
1104
+ function rebuildNodeCell(info, cell, node, chain) {
1105
+ // 单元跨度是**相对页体(mxGraphModel)**的,不是相对整份文件 —— 切错参照系会把
1106
+ // 页面开头的几十个字符当成这个单元的原文(实测:改一个坐标把 mxGraphModel 标签搬进了单元里)。
1107
+ let original = info.modelXml.slice(cell.start, cell.end)
1108
+ let subject = cell
1109
+ const data = node.data !== undefined && node.data !== null && typeof node.data === 'object' ? node.data : null
1110
+ // 还没有包装、却有了数据 → 先包一层(drawio 的用户对象形态),再照常改。
1111
+ // 包装会改变各处跨度,所以必须**先包、再重新定位**,否则后面的属性改写会写错位置。
1112
+ if (data !== null && cell.wrapper === null) {
1113
+ const wrapped = wrapAsObject(cell, original, data)
1114
+ const fresh = rescanCell(wrapped)
1115
+ if (fresh !== null) {
1116
+ original = wrapped
1117
+ subject = fresh
1118
+ }
1119
+ }
1120
+ const changes = []
1121
+ const label = typeof node.label === 'string' ? node.label : ''
1122
+ if (label !== labelFromValue(subject.label)) {
1123
+ const span = subject.wrapper !== null ? subject.wrapper : subject.attrsSpan
1124
+ changes.push({
1125
+ attrsStart: span.attrsStart,
1126
+ attrsEnd: span.attrsEnd,
1127
+ pairs: { [subject.wrapper !== null ? 'label' : 'value']: escapeAttr(label) },
1128
+ })
1129
+ }
1130
+ const style = typeof node.style === 'string' ? node.style : ''
1131
+ if (stylesEqual(subject.style, style, false) === false) {
1132
+ changes.push({ attrsStart: subject.attrsSpan.attrsStart, attrsEnd: subject.attrsSpan.attrsEnd, pairs: { style: style.length > 0 ? escapeAttr(style) : null } })
1133
+ }
1134
+ if (data !== null) {
1135
+ const entry = dataChangeFor(subject, data)
1136
+ if (entry !== null) changes.push(entry)
1137
+ }
1138
+
1139
+ const offset = chain.offset
1140
+ const x = (Number.isFinite(Number(node.x)) ? Number(node.x) : 0) - offset.x
1141
+ const y = (Number.isFinite(Number(node.y)) ? Number(node.y) : 0) - offset.y
1142
+ const w = Number.isFinite(Number(node.w)) ? Number(node.w) : 120
1143
+ const h = Number.isFinite(Number(node.h)) ? Number(node.h) : 60
1144
+ // 父级换了(原来的容器被删了)也要写回,否则文件里留下悬空 parent。
1145
+ if (typeof chain.parentId === 'string' && chain.parentId !== subject.parent) {
1146
+ changes.push({ attrsStart: subject.attrsSpan.attrsStart, attrsEnd: subject.attrsSpan.attrsEnd, pairs: { parent: escapeAttr(chain.parentId) } })
1147
+ }
1148
+ const geo = subject.geometry
1149
+ const unchanged = geo !== null && geo.x === x && geo.y === y && geo.width === w && geo.height === h
1150
+ if (unchanged) return withAttrChanges(original, changes)
1151
+
1152
+ if (subject.geo !== null && subject.geo.selfClosing) {
1153
+ // 只有属性、没有子元素:连 x/y/width/height 一起在一次批量改写里做(同一区域!)
1154
+ const pairs = { x: String(x), y: String(y), width: String(w), height: String(h) }
1155
+ if (attrIn(original, subject.geo.attrsStart, subject.geo.attrsEnd, 'as') === undefined) pairs.as = 'geometry'
1156
+ changes.push({ attrsStart: subject.geo.attrsStart, attrsEnd: subject.geo.attrsEnd, pairs: pairs })
1157
+ return withAttrChanges(original, changes)
1158
+ }
1159
+ if (subject.geo !== null) {
1160
+ // 几何带子元素(例如 `<mxRectangle as="alternateBounds">`):只改属性,子元素原样留着。
1161
+ const pairs = { x: String(x), y: String(y), width: String(w), height: String(h) }
1162
+ if (attrIn(original, subject.geo.attrsStart, subject.geo.attrsEnd, 'as') === undefined) pairs.as = 'geometry'
1163
+ changes.push({ attrsStart: subject.geo.attrsStart, attrsEnd: subject.geo.attrsEnd, pairs: pairs })
1164
+ return withAttrChanges(original, changes)
1165
+ }
1166
+ // 本来没有几何:先做属性改写,**重新定位**后再补几何。
1167
+ const withAttrs = withAttrChanges(original, changes)
1168
+ const fresh = rescanCell(withAttrs)
1169
+ const geometry = '<mxGeometry x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" as="geometry" />'
1170
+ return fresh === null ? withAttrs : appendGeometry(withAttrs, fresh, geometry)
1171
+ }
1172
+
1173
+ /** 改写一条边:标签、style、端点、折点/自由点(几何内部只在**语义**变了时才重建)。 */
1174
+ function rebuildEdgeCell(info, cell, edge, chain) {
1175
+ const original = info.modelXml.slice(cell.start, cell.end)
1176
+ const changes = []
1177
+ const label = typeof edge.label === 'string' ? edge.label : ''
1178
+ if (label !== labelFromValue(cell.label)) {
1179
+ const span = cell.wrapper !== null ? cell.wrapper : cell.attrsSpan
1180
+ changes.push({
1181
+ attrsStart: span.attrsStart,
1182
+ attrsEnd: span.attrsEnd,
1183
+ pairs: { [cell.wrapper !== null ? 'label' : 'value']: escapeAttr(label) },
1184
+ })
1185
+ }
1186
+ const style = typeof edge.style === 'string' ? edge.style : ''
1187
+ if (stylesEqual(cell.style, style, true) === false) {
1188
+ changes.push({ attrsStart: cell.attrsSpan.attrsStart, attrsEnd: cell.attrsSpan.attrsEnd, pairs: { style: style.length > 0 ? escapeAttr(style) : null } })
1189
+ }
1190
+ // 端点:只在真的换了落点时才动属性(同一区域,必须并进同一次批量改写)
1191
+ const from = typeof edge.from === 'string' ? edge.from : undefined
1192
+ const to = typeof edge.to === 'string' ? edge.to : undefined
1193
+ const endpointPairs = {}
1194
+ if (cell.source !== from) endpointPairs.source = from === undefined ? null : escapeAttr(from)
1195
+ if (cell.target !== to) endpointPairs.target = to === undefined ? null : escapeAttr(to)
1196
+ if (Object.keys(endpointPairs).length > 0) {
1197
+ changes.push({ attrsStart: cell.attrsSpan.attrsStart, attrsEnd: cell.attrsSpan.attrsEnd, pairs: endpointPairs })
1198
+ }
1199
+
1200
+ // 几何:折点 / 自由端点 / **标签位置**。语义相同就一个字节都不动(保住幂等与最小 diff)。
1201
+ const geo = cell.geometry
1202
+ const parsedPoints = geo !== null && Array.isArray(geo.points) ? geo.points : []
1203
+ const modelPoints = Array.isArray(edge.points) ? edge.points : []
1204
+ const parsedSource = geo === null ? null : geo.sourcePoint
1205
+ const parsedTarget = geo === null ? null : geo.targetPoint
1206
+ const modelSource = from === undefined && edge.sourcePoint !== undefined && edge.sourcePoint !== null ? edge.sourcePoint : null
1207
+ const modelTarget = to === undefined && edge.targetPoint !== undefined && edge.targetPoint !== null ? edge.targetPoint : null
1208
+ // 标签位置(drawio 的 mxGeometry x/y + offset):模型里有就写、没有但文件里有就删(归中)。
1209
+ const filePos = relativeLabelPos(geo)
1210
+ const wantPos = modelLabelPos(edge)
1211
+ if (labelPosChanged(filePos, wantPos)) {
1212
+ changes.push({
1213
+ attrsStart: cell.geo.attrsStart,
1214
+ attrsEnd: cell.geo.attrsEnd,
1215
+ pairs: { x: wantPos === null ? null : String(wantPos.x), y: wantPos === null ? null : String(wantPos.y) },
1216
+ })
1217
+ }
1218
+ const parsedOffset = labelOffsetPoint(filePos)
1219
+ const modelOffset = labelOffsetPoint(wantPos)
1220
+ const innerUnchanged =
1221
+ pointsEqual(parsedPoints, modelPoints) && samePoint(parsedSource, modelSource) && samePoint(parsedTarget, modelTarget) && samePoint(parsedOffset, modelOffset)
1222
+ const text = withAttrChanges(original, changes)
1223
+ if (innerUnchanged) return text
1224
+
1225
+ const fresh = rescanCell(text)
1226
+ if (fresh === null) return text
1227
+ const inner = edgeInnerXml({ points: modelPoints, sourcePoint: modelSource, targetPoint: modelTarget, offset: modelOffset }, '')
1228
+ // 几何标签上的属性(x/y/relative/as…)**原样保留**:我们只往上面改 x/y(见上面的 changes),
1229
+ // 重建时不能把它们抹掉 —— 否则"拖过标签的边"一改折点就把位置丢了。
1230
+ const keepAttrs = fresh.geo === null ? '' : text.slice(fresh.geo.attrsStart, fresh.geo.attrsEnd)
1231
+ const geometry = inner.length === 0 ? '<mxGeometry' + keepAttrs + ' />' : '<mxGeometry' + keepAttrs + '>' + inner + '</mxGeometry>'
1232
+ if (fresh.geo === null) return appendGeometry(text, fresh, geometry)
1233
+ if (fresh.geo.selfClosing) return text.slice(0, fresh.geo.start) + geometry + text.slice(fresh.geo.end)
1234
+ return text.slice(0, fresh.geo.innerFrom) + inner + text.slice(fresh.geo.innerTo)
1235
+ }
1236
+
1237
+ /**
1238
+ * 算出"要把原文件改成什么样"的**编辑计划**(不改文本)。
1239
+ *
1240
+ * 抽出来是为了两件事:自测能直接断言"没改动时编辑数为 0"(无损的第一条不变量),
1241
+ * 以及出问题时能看见每条编辑落在哪儿 —— 偏移错了写出来的文件会当场花掉。
1242
+ *
1243
+ * @returns { info, edits, dropped }
1244
+ */
1245
+ function planDocEdits(originalText, doc) {
1246
+ const source = String(originalText === undefined || originalText === null ? '' : originalText)
1247
+ const info = analyzeMxfile(source)
1248
+ const owned = ownershipOf(info).owned
1249
+ const nodes = Array.isArray(doc.nodes) ? doc.nodes : []
1250
+ const edges = Array.isArray(doc.edges) ? doc.edges : []
1251
+ const nodeIds = new Set(nodes.map((n) => String(n.id)))
1252
+ const wanted = new Map()
1253
+ for (const node of nodes) if (node !== null && typeof node === 'object') wanted.set(String(node.id), { kind: 'node', item: node })
1254
+ for (const edge of edges) if (edge !== null && typeof edge === 'object') wanted.set(String(edge.id), { kind: 'edge', item: edge })
1255
+
1256
+ /** 一条边的落点:doc 里的顶点才算真实落点,否则用自由点;两者都没有就丢掉它。 */
1257
+ const anchorsOf = (edge) => {
1258
+ const from = typeof edge.from === 'string' && nodeIds.has(edge.from) ? edge.from : undefined
1259
+ const to = typeof edge.to === 'string' && nodeIds.has(edge.to) ? edge.to : undefined
1260
+ const sourcePoint = edge.sourcePoint !== undefined && edge.sourcePoint !== null ? edge.sourcePoint : undefined
1261
+ const targetPoint = edge.targetPoint !== undefined && edge.targetPoint !== null ? edge.targetPoint : undefined
1262
+ if (from === undefined && sourcePoint === undefined) return null
1263
+ if (to === undefined && targetPoint === undefined) return null
1264
+ return Object.assign({}, edge, { from: from, to: to, sourcePoint: sourcePoint, targetPoint: targetPoint })
1265
+ }
1266
+
1267
+ const dropped = []
1268
+ const edits = []
1269
+ // 画布上的绝对坐标:写回时容器子单元的相对坐标要按它反算。
1270
+ const modelAbs = new Map()
1271
+ for (const node of nodes) {
1272
+ if (node === null || typeof node !== 'object') continue
1273
+ modelAbs.set(String(node.id), { x: Number.isFinite(Number(node.x)) ? Number(node.x) : 0, y: Number.isFinite(Number(node.y)) ? Number(node.y) : 0 })
1274
+ }
1275
+ // 被删掉的单元(我们导入过、模型里没有了)。它们若是别人的父级,下面是"孤儿",要接走。
1276
+ const deletedIds = new Set()
1277
+ for (const cell of info.cells) {
1278
+ const id = String(cell.id)
1279
+ if (owned.has(id) && wanted.get(id) === undefined) deletedIds.add(id)
1280
+ }
1281
+ // 已知的层 id = 文件里的层 ∪ 模型里的层(模型里可能有这次刚新建的层)。
1282
+ const docLayers = Array.isArray(doc.layers) ? doc.layers : []
1283
+ const knownLayers = new Set(info.layerIds)
1284
+ for (const layer of docLayers) {
1285
+ if (layer !== null && typeof layer === 'object' && typeof layer.id === 'string') knownLayers.add(layer.id)
1286
+ }
1287
+ for (const cell of info.cells) {
1288
+ const id = String(cell.id)
1289
+ const want = wanted.get(id)
1290
+ if (want === undefined) {
1291
+ // 我们导入过、而模型里没有了 → 用户真的删了它。
1292
+ // 我们**没**导入过的(不认识的边)不是删除,原样留着。
1293
+ if (owned.has(id)) edits.push({ start: cell.start, end: cell.end, text: '' })
1294
+ continue
1295
+ }
1296
+ const chain = writeChainOf(info, modelAbs, deletedIds, cell, want.item.layer, knownLayers)
1297
+ if (want.kind === 'edge') {
1298
+ const anchors = anchorsOf(want.item)
1299
+ if (anchors === null) {
1300
+ dropped.push(id)
1301
+ edits.push({ start: cell.start, end: cell.end, text: '' })
1302
+ continue
1303
+ }
1304
+ const rebuilt = rebuildEdgeCell(info, cell, anchors, chain)
1305
+ if (rebuilt !== info.modelXml.slice(cell.start, cell.end)) edits.push({ start: cell.start, end: cell.end, text: rebuilt })
1306
+ continue
1307
+ }
1308
+ const rebuilt = rebuildNodeCell(info, cell, want.item, chain)
1309
+ if (rebuilt !== info.modelXml.slice(cell.start, cell.end)) edits.push({ start: cell.start, end: cell.end, text: rebuilt })
1310
+ }
1311
+
1312
+ // ── 顺序(z-order):drawio 的"谁在上面"就是**单元在 root 里的先后** ──
1313
+ //
1314
+ // 模型里节点的先后 = 画布上的覆盖顺序,所以"置顶/置底"改的是模型数组顺序;
1315
+ // 文件那边必须跟着改,否则重新载入(或 drawio 打开)又变回原来的层叠顺序。
1316
+ //
1317
+ // 只在**真的变了**的时候才动:读进来时模型的先后就是文件里的先后,
1318
+ // 于是"打开后原样保存逐字节不变"这条性质不受影响(下面的 reorderKind 会先比对)。
1319
+ const moveEdits = planReorder(info, edits, nodes, edges, owned)
1320
+ for (const edit of moveEdits) edits.push(edit)
1321
+
1322
+ // 我们不认识的单元如果挂在被删的容器下,也要接走 —— 否则文件里留下悬空 parent。
1323
+ // (我们不"拥有"它,所以只改 parent,不碰它的其他属性。)
1324
+ if (deletedIds.size > 0) {
1325
+ for (const cell of info.cells) {
1326
+ const id = String(cell.id)
1327
+ if (deletedIds.has(id) || wanted.get(id) !== undefined) continue
1328
+ if (typeof cell.parent !== 'string' || deletedIds.has(cell.parent) === false) continue
1329
+ const chain = writeChainOf(info, modelAbs, deletedIds, cell)
1330
+ const original = info.modelXml.slice(cell.start, cell.end)
1331
+ const rebuilt = setAttrsIn(original, cell.attrsSpan.attrsStart, cell.attrsSpan.attrsEnd, { parent: escapeAttr(chain.parentId) })
1332
+ if (rebuilt !== original) edits.push({ start: cell.start, end: cell.end, text: rebuilt })
1333
+ }
1334
+ }
1335
+
1336
+ // 新单元:插到最后一个单元之后(parent 用**它自己那一层**,没写就用第一个图层)
1337
+ //
1338
+ // 缩进与插入点都**跟着这个文件走**,不硬编码:硬编码会留下多余空白,
1339
+ // 于是"只追加了一个单元"变成"顺带改了别处的字节",逐字节不变的性质就没了。
1340
+ const layerId = info.layerOrder.length > 0 ? info.layerOrder[0] : info.rootId
1341
+ const layerById = new Map()
1342
+ for (const layer of docLayers) if (layer !== null && typeof layer === 'object' && typeof layer.id === 'string') layerById.set(layer.id, layer)
1343
+ const parentFor = (cell) => {
1344
+ const want = cell !== null && typeof cell === 'object' && typeof cell.layer === 'string' ? cell.layer : null
1345
+ if (want !== null && (layerById.has(want) === true || info.layerIds.has(want) === true)) return want
1346
+ return layerId
1347
+ }
1348
+ const rootLineStart = info.modelXml.lastIndexOf('\n', info.innerTo)
1349
+ const rootIndent = rootLineStart < 0 ? '' : info.modelXml.slice(rootLineStart + 1, info.innerTo)
1350
+ const childIndent = rootIndent + ' '
1351
+ const additions = []
1352
+ for (const node of nodes) {
1353
+ if (node === null || typeof node !== 'object' || info.byId.has(String(node.id))) continue
1354
+ additions.push(nodeCellXml(node, parentFor(node), childIndent))
1355
+ }
1356
+ for (const edge of edges) {
1357
+ if (edge === null || typeof edge !== 'object' || info.byId.has(String(edge.id))) continue
1358
+ const anchors = anchorsOf(edge)
1359
+ if (anchors === null) {
1360
+ dropped.push(String(edge.id))
1361
+ continue
1362
+ }
1363
+ additions.push(edgeCellXml(anchors, parentFor(edge), childIndent))
1364
+ }
1365
+
1366
+ // 图层:① 已存在的层,改了名字/可见/锁定就做**属性级**定点改写(不动别的字节);
1367
+ // ② 模型里有、文件里没有的层 → 新建一个图层单元。
1368
+ // 删除图层(以及层内的单元)留到 v2:那要连单元一起删,属于另一件事。
1369
+ for (const layer of docLayers) {
1370
+ if (layer === null || typeof layer !== 'object' || typeof layer.id !== 'string') continue
1371
+ const cell = info.byId.get(layer.id)
1372
+ const name = typeof layer.name === 'string' ? layer.name : ''
1373
+ const visible = layer.visible !== false
1374
+ const locked = layer.locked === true
1375
+ if (cell === undefined) {
1376
+ additions.push(layerCellXml(layer.id, String(name), visible, locked, childIndent))
1377
+ continue
1378
+ }
1379
+ if (info.layerIds.has(layer.id) === false) continue
1380
+ const original = info.modelXml.slice(cell.start, cell.end)
1381
+ const span = cell.wrapper !== null ? cell.wrapper : cell.attrsSpan
1382
+ const current = info.layers.filter((l) => l.id === layer.id)[0]
1383
+ const want = { value: name, visible: visible ? null : '0', locked: locked ? '1' : null }
1384
+ const had = current === undefined ? { name: '', visible: true, locked: false } : current
1385
+ if (had.name === name && had.visible === visible && had.locked === locked) continue
1386
+ const rebuilt = setAttrsIn(original, span.attrsStart, span.attrsEnd, want)
1387
+ if (rebuilt !== original) edits.push({ start: cell.start, end: cell.end, text: rebuilt })
1388
+ }
1389
+
1390
+ // 元数据单元(pin 状态):有就更新,需要而没有就新建,不需要就删掉
1391
+ const metaCell = info.byId.get(META_ID)
1392
+ const wantPinned = doc.meta !== undefined && doc.meta !== null && doc.meta.pinned === true
1393
+ if (metaCell !== undefined) {
1394
+ if (wantPinned === false) {
1395
+ edits.push({ start: metaCell.start, end: metaCell.end, text: '' })
1396
+ } else {
1397
+ const original = info.modelXml.slice(metaCell.start, metaCell.end)
1398
+ const span = metaCell.wrapper !== null ? metaCell.wrapper : metaCell.attrsSpan
1399
+ const rebuilt = attrIn(original, span.attrsStart, span.attrsEnd, 'drawaiPinned') === '1'
1400
+ ? original
1401
+ : setAttrIn(original, span.attrsStart, span.attrsEnd, 'drawaiPinned', '1')
1402
+ if (rebuilt !== original) edits.push({ start: metaCell.start, end: metaCell.end, text: rebuilt })
1403
+ }
1404
+ } else if (wantPinned === true) {
1405
+ additions.push(metaCellXml(layerId, childIndent, doc.meta))
1406
+ }
1407
+ if (additions.length > 0) {
1408
+ const insertAt = rootLineStart < 0 ? info.innerTo : rootLineStart
1409
+ edits.push({ start: insertAt, end: insertAt, text: '\n' + additions.join('\n') })
1410
+ }
1411
+
1412
+ return { info: info, edits: edits, dropped: dropped }
1413
+ }
1414
+
1415
+ /**
1416
+ * 顺序改动 → 编辑计划(把"文件里这些单元的先后"改成模型里的先后)。
1417
+ *
1418
+ * 为什么不能不做:`置顶/置底`在画布上改的是模型数组顺序,而文件里单元的先后**就是**层叠顺序——
1419
+ * 不写回去的话,重新载入或拿 drawio 打开又变回原样,用户会觉得"置顶没生效"。
1420
+ *
1421
+ * 做法:先算文件里"我们拥有的这一种单元"的 id 序列,和模型里的序列比;一样就**一条编辑都不发**
1422
+ * (这条保证了"打开后原样保存逐字节不变")。不一样时,把位置不对的单元挪到目标位置前面:
1423
+ * 删掉原位置 + 在目标行首插入(缩进沿用目标行)。
1424
+ *
1425
+ * 节点与边**分开**比对:模型里节点和边是两个数组,而文件里它们可能交错排列;
1426
+ * 拿 `[...nodes, ...edges]` 去比会把交错的文件整体重排一遍(每次保存都大改,绝对不能这么干)。
1427
+ */
1428
+ function planReorder(info, edits, nodes, edges, owned) {
1429
+ const out = []
1430
+ const editedText = new Map()
1431
+ for (const edit of edits) if (edit.end > edit.start) editedText.set(edit.start, edit.text)
1432
+ const deleted = new Set()
1433
+ for (const edit of edits) if (edit.end > edit.start && edit.text === '') deleted.add(edit.start)
1434
+
1435
+ const orderOf = (wanted) => {
1436
+ const list = []
1437
+ for (const cell of info.cells) {
1438
+ if (wanted.has(String(cell.id))) list.push(cell)
1439
+ }
1440
+ return list
1441
+ }
1442
+ const lineStartOf = (offset) => {
1443
+ const at = info.modelXml.lastIndexOf('\n', offset)
1444
+ return at < 0 ? 0 : at + 1
1445
+ }
1446
+ const indentOf = (offset) => info.modelXml.slice(lineStartOf(offset), offset)
1447
+
1448
+ const kinds = [
1449
+ { wanted: new Set(nodes.map((n) => String(n.id))), model: nodes.map((n) => String(n.id)) },
1450
+ { wanted: new Set(edges.map((e) => String(e.id))), model: edges.map((e) => String(e.id)) },
1451
+ ]
1452
+ for (const kind of kinds) {
1453
+ const fileCells = orderOf(kind.wanted)
1454
+ // 只考虑"文件里有、模型里也有"的单元(新单元由 additions 负责;被删的已经没了)。
1455
+ const current = fileCells.filter((c) => kind.model.indexOf(String(c.id)) >= 0).map((c) => String(c.id))
1456
+ if (current.join('\u0000') === kind.model.join('\u0000')) continue
1457
+ // 有删除/新增混在一起时顺序本来就会变,那种情况不折腾(新增在末尾、删除不影响相对顺序)。
1458
+ if (fileCells.length !== kind.model.length) continue
1459
+ const byId = new Map()
1460
+ for (const cell of fileCells) byId.set(String(cell.id), cell)
1461
+
1462
+ // **一次插入**,而不是逐个移动:逐个"插到同一个锚点前面",最终先后取决于 splice 的应用顺序,
1463
+ // 实测会插反([n1,n2,n3] 置顶 n1 得到 n3,n1,n2)。所以:
1464
+ // 把这些单元各自删掉,再在**最早那个单元的行首**按模型顺序整体插回。
1465
+ // 中间夹着的、不归我们管的单元不受影响(只是位置跟着挪)。
1466
+ const texts = []
1467
+ let firstAt = -1
1468
+ for (const id of kind.model) {
1469
+ const cell = byId.get(id)
1470
+ if (cell === undefined) continue
1471
+ texts.push(indentOf(cell.start) + (editedText.has(cell.start) ? editedText.get(cell.start) : info.modelXml.slice(cell.start, cell.end)))
1472
+ if (deleted.has(cell.start) === false) out.push({ start: cell.start, end: cell.end, text: '' })
1473
+ if (firstAt < 0 || cell.start < firstAt) firstAt = cell.start
1474
+ }
1475
+ if (firstAt < 0 || texts.length === 0) continue
1476
+ const at = lineStartOf(firstAt)
1477
+ out.push({ start: at, end: at, text: texts.join('\n') + '\n' })
1478
+ }
1479
+ void owned
1480
+ return out
1481
+ }
1482
+
1483
+ /**
1484
+ * 把语义文档写回一份**已有的** mxfile 文本。
1485
+ *
1486
+ * 只改我们拥有的单元(见文件头),其余原文逐字节保留;原本压缩的页体写回后仍压缩。
1487
+ *
1488
+ * @returns { text, dropped } —— dropped 是被略过的边 id(两端都没有落点),调用方应如实上报
1489
+ */
1490
+ export function applyDocToMxfile(originalText, doc, options) {
1491
+ const source = String(originalText === undefined || originalText === null ? '' : originalText)
1492
+ const plan = planDocEdits(source, doc)
1493
+ const model = spliceEdits(plan.info.modelXml, plan.edits)
1494
+ const bodyText = plan.info.compressed ? compressModel(model) : model
1495
+ return {
1496
+ text: source.slice(0, plan.info.bodyStart) + bodyText + source.slice(plan.info.bodyEnd),
1497
+ dropped: plan.dropped,
1498
+ }
1499
+ }
1500
+
1501
+ // ---- 自测钩子 ---------------------------------------------------------------
1502
+
1503
+ /**
1504
+ * 仅供自测:暴露全文分析(页 / 图层 / 归属 / 每个单元的文本跨度)。
1505
+ *
1506
+ * 写回是"按偏移做定点手术",偏移错了就会写出花掉的文件 —— 所以要能直接把跨度
1507
+ * 拿出来断言,而不是靠"看起来对"。归属判据也一并暴露:读与写必须用同一套。
1508
+ */
1509
+ export function __analyze(text) {
1510
+ const info = analyzeMxfile(String(text === undefined || text === null ? '' : text))
1511
+ const ownership = ownershipOf(info)
1512
+ return {
1513
+ pageCount: info.pageCount,
1514
+ compressed: info.compressed,
1515
+ bodyStart: info.bodyStart,
1516
+ bodyEnd: info.bodyEnd,
1517
+ rootId: info.rootId,
1518
+ layerOrder: info.layerOrder,
1519
+ innerFrom: info.innerFrom,
1520
+ innerTo: info.innerTo,
1521
+ owned: [...ownership.owned],
1522
+ nodeIds: [...ownership.nodeIds],
1523
+ cells: info.cells.map((cell) => ({
1524
+ id: cell.id,
1525
+ element: cell.element,
1526
+ start: cell.start,
1527
+ end: cell.end,
1528
+ parent: cell.parent,
1529
+ vertex: cell.vertex,
1530
+ edge: cell.edge,
1531
+ attrsSpan: cell.attrsSpan,
1532
+ wrapper: cell.wrapper,
1533
+ geo:
1534
+ cell.geo === null
1535
+ ? null
1536
+ : {
1537
+ attrsStart: cell.geo.attrsStart,
1538
+ attrsEnd: cell.geo.attrsEnd,
1539
+ selfClosing: cell.geo.selfClosing,
1540
+ start: cell.geo.start,
1541
+ end: cell.geo.end,
1542
+ innerFrom: cell.geo.innerFrom,
1543
+ innerTo: cell.geo.innerTo,
1544
+ },
1545
+ })),
1546
+ }
1547
+ }
1548
+
1549
+ /**
1550
+ * 仅供自测:暴露**编辑计划**(每条编辑的范围与预览)。
1551
+ * "打开后原样保存 ⇒ 编辑数为 0" 是写回无损的第一条不变量,必须能直接断言。
1552
+ */
1553
+ export function __plan(text, doc) {
1554
+ const plan = planDocEdits(text, doc)
1555
+ return {
1556
+ dropped: plan.dropped,
1557
+ info: { bodyStart: plan.info.bodyStart, bodyEnd: plan.info.bodyEnd, innerTo: plan.info.innerTo, compressed: plan.info.compressed },
1558
+ edits: plan.edits.map((edit) => ({
1559
+ start: edit.start,
1560
+ end: edit.end,
1561
+ insert: edit.start === edit.end,
1562
+ old: plan.info.modelXml.slice(edit.start, Math.min(edit.end, edit.start + 40)),
1563
+ text: edit.text.slice(0, 60),
1564
+ })),
1565
+ }
1566
+ }
1567
+
1568
+ // ---- 从零生成一份 mxfile(新建 / 另存为用)----------------------------------
1569
+ /**
1570
+ * 文档 → 全新的 mxfile。默认**不压缩**(人可读、diff 友好;drawio 两种都认)。
1571
+ *
1572
+ * 注意:这是"从零生成",只写模型里的东西 —— 所以只用于新建文件;
1573
+ * 覆盖已有文件一律走 `applyDocToMxfile`(无损写回)。
1574
+ *
1575
+ * @returns { text, dropped }
1576
+ */
1577
+ export function buildMxfile(doc, options) {
1578
+ const compressed = options !== undefined && options.compressed === true
1579
+ const pageName = options !== undefined && typeof options.name === 'string' && options.name.length > 0 ? options.name : 'Page-1'
1580
+ const nodes = Array.isArray(doc.nodes) ? doc.nodes : []
1581
+ const edges = Array.isArray(doc.edges) ? doc.edges : []
1582
+ const nodeIds = new Set(nodes.map((n) => String(n.id)))
1583
+ const dropped = []
1584
+ const lines = []
1585
+ // 纸张:模型里带了就照它写(读进来是什么尺寸,从零重建时还是什么尺寸),没带就用 drawio 的缺省。
1586
+ const pageW = doc.page !== undefined && doc.page !== null && Number.isFinite(Number(doc.page.w)) && Number(doc.page.w) > 0 ? Number(doc.page.w) : PAGE_W
1587
+ const pageH = doc.page !== undefined && doc.page !== null && Number.isFinite(Number(doc.page.h)) && Number(doc.page.h) > 0 ? Number(doc.page.h) : PAGE_H
1588
+ lines.push('<mxfile host="dsh-drawai" agent="dsh-drawai" type="device">')
1589
+ lines.push(' <diagram id="dsh-drawai-page-1" name="' + escapeAttr(pageName) + '">')
1590
+ lines.push(
1591
+ ' <mxGraphModel dx="0" dy="0" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="' +
1592
+ pageW +
1593
+ '" pageHeight="' +
1594
+ pageH +
1595
+ '" math="0" shadow="0">',
1596
+ )
1597
+ lines.push(' <root>')
1598
+ lines.push(' <mxCell id="0" />')
1599
+ // 图层:模型里给了就用它(第一层的 id 取代缺省的 "1"),没给就写缺省的 `<mxCell id="1" parent="0" />`。
1600
+ const layers = Array.isArray(doc.layers) ? doc.layers.filter((l) => l !== null && typeof l === 'object' && typeof l.id === 'string') : []
1601
+ for (const layer of layers) {
1602
+ lines.push(
1603
+ layerCellXml(
1604
+ layer.id,
1605
+ typeof layer.name === 'string' ? layer.name : '',
1606
+ layer.visible !== false,
1607
+ layer.locked === true,
1608
+ ' ',
1609
+ ),
1610
+ )
1611
+ }
1612
+ if (layers.length === 0) lines.push(' <mxCell id="1" parent="0" />')
1613
+ const firstLayerId = layers.length > 0 ? String(layers[0].id) : '1'
1614
+ const parentOf = (cell) => {
1615
+ const want = cell !== null && typeof cell === 'object' && typeof cell.layer === 'string' ? cell.layer : null
1616
+ if (want !== null && layers.some((l) => String(l.id) === want)) return want
1617
+ return firstLayerId
1618
+ }
1619
+ if (doc.meta !== undefined && doc.meta !== null && doc.meta.pinned === true) lines.push(metaCellXml(parentOf(doc.meta), ' ', doc.meta))
1620
+ for (const node of nodes) {
1621
+ if (node === null || typeof node !== 'object') continue
1622
+ lines.push(nodeCellXml(node, parentOf(node), ' '))
1623
+ }
1624
+ for (const edge of edges) {
1625
+ if (edge === null || typeof edge !== 'object') continue
1626
+ const from = typeof edge.from === 'string' && nodeIds.has(edge.from) ? edge.from : undefined
1627
+ const to = typeof edge.to === 'string' && nodeIds.has(edge.to) ? edge.to : undefined
1628
+ const sourcePoint = edge.sourcePoint !== undefined && edge.sourcePoint !== null ? edge.sourcePoint : undefined
1629
+ const targetPoint = edge.targetPoint !== undefined && edge.targetPoint !== null ? edge.targetPoint : undefined
1630
+ if (from === undefined && sourcePoint === undefined) {
1631
+ dropped.push(String(edge.id))
1632
+ continue
1633
+ }
1634
+ if (to === undefined && targetPoint === undefined) {
1635
+ dropped.push(String(edge.id))
1636
+ continue
1637
+ }
1638
+ lines.push(
1639
+ edgeCellXml(
1640
+ Object.assign({}, edge, { from: from, to: to, sourcePoint: sourcePoint, targetPoint: targetPoint }),
1641
+ parentOf(edge),
1642
+ ' ',
1643
+ ),
1644
+ )
1645
+ }
1646
+ lines.push(' </root>')
1647
+ lines.push(' </mxGraphModel>')
1648
+ const model = lines.slice(2).join('\n')
1649
+ const head = lines.slice(0, 2).join('\n')
1650
+ const tail = [' </diagram>', '</mxfile>', ''].join('\n')
1651
+ if (!compressed) return { text: head + '\n' + model + '\n' + tail, dropped: dropped }
1652
+ return {
1653
+ text:
1654
+ '<mxfile host="dsh-drawai" agent="dsh-drawai" type="device">\n <diagram id="dsh-drawai-page-1" name="' +
1655
+ escapeAttr(pageName) +
1656
+ '">\n ' +
1657
+ compressModel(model) +
1658
+ '\n </diagram>\n</mxfile>\n',
1659
+ dropped: dropped,
1660
+ }
1661
+ }