dsh-drawai 0.2.6 → 0.3.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 CHANGED
@@ -53,16 +53,44 @@ export const META_ID = 'drawai-meta'
53
53
 
54
54
  // ---- 文本与属性小工具(mxfile 的属性/文本足够用,不引入 XML 依赖)-----------
55
55
 
56
- /** 解码 XML 实体(含数字实体)。*/
56
+ /** 常见命名实体(数字实体之外;`&` 必须最后解)。*/
57
+ const NAMED_ENTITIES = {
58
+ nbsp: '\u00A0',
59
+ ensp: '\u2002',
60
+ emsp: '\u2003',
61
+ thinsp: '\u2009',
62
+ mdash: '\u2014',
63
+ ndash: '\u2013',
64
+ lsquo: '\u2018',
65
+ rsquo: '\u2019',
66
+ ldquo: '\u201C',
67
+ rdquo: '\u201D',
68
+ hellip: '\u2026',
69
+ middot: '\u00B7',
70
+ bull: '\u2022',
71
+ copy: '\u00A9',
72
+ reg: '\u00AE',
73
+ trade: '\u2122',
74
+ deg: '\u00B0',
75
+ times: '\u00D7',
76
+ divide: '\u00F7',
77
+ }
78
+
79
+ /** 解码 XML 实体(含数字实体与常见命名实体)。*/
57
80
  function decodeEntities(text) {
58
81
  return String(text)
59
82
  .replace(/&#x([0-9a-fA-F]+);/g, (m, hex) => String.fromCodePoint(parseInt(hex, 16)))
60
83
  .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, '&')
84
+ .replace(/&([a-zA-Z]+);/g, (m, name) => {
85
+ const key = name.toLowerCase()
86
+ if (key === 'lt') return '<'
87
+ if (key === 'gt') return '>'
88
+ if (key === 'quot') return '"'
89
+ if (key === 'apos') return "'"
90
+ if (key === 'amp') return '&'
91
+ const named = NAMED_ENTITIES[key]
92
+ return named === undefined ? m : named
93
+ })
66
94
  }
67
95
 
68
96
  /** 属性值转义(写回用)。*/
@@ -194,8 +222,12 @@ function attrIn(text, attrsStart, attrsEnd, name) {
194
222
  /**
195
223
  * 把 drawio 的标签文本变成"纯文本标签"。
196
224
  *
197
- * 我们按纯文本渲染标签,而 drawio 的标签常带 HTML(`html=1` + `<br>`、`<b>`…)。
198
- * 所以把 `<br>` 折成空格、去掉其余标签,再解实体 —— 读起来才是人写的那句话。
225
+ * 我们按纯文本渲染标签,而 drawio 的标签常带 HTML(`html=1` + `<br>`、`<b>`、表格…)。
226
+ * 折算规则(有损但可读):
227
+ * · `<br>` / 块级收尾标签 → 换行;
228
+ * · `<td>` / `<th>` 之间留空格(表格粗折算成列);
229
+ * · 其余标签剥掉;命名实体(含 `&nbsp;`)解码;
230
+ * · 行内空白折叠,**换行保留**。
199
231
  * 代价:改过标签的单元写回时 markup 回不去(如实记在 notes 里)。
200
232
  */
201
233
  function labelFromValue(value) {
@@ -203,9 +235,23 @@ function labelFromValue(value) {
203
235
  let text = String(value)
204
236
  if (text.indexOf('<') < 0 && text.indexOf('&') < 0) return text
205
237
  const hadMarkup = /<[a-zA-Z/][^>]*>/.test(text)
206
- text = text.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]*>/g, '')
238
+ // 先把换行语义钉死,再剥其余标签 —— 顺序反了会丢行结构。
239
+ text = text
240
+ .replace(/<br\s*\/?>/gi, '\n')
241
+ .replace(/<\/(?:div|p|tr|li|h[1-6])\s*>/gi, '\n')
242
+ .replace(/<\/(?:td|th)\s*>/gi, ' ')
243
+ .replace(/<(?:td|th)\b[^>]*>/gi, '')
244
+ .replace(/<[^>]*>/g, '')
207
245
  text = decodeEntities(text)
208
- return hadMarkup ? text.replace(/\s+/g, ' ').trim() : text
246
+ if (!hadMarkup) return text
247
+ // 折叠行内空白,保留换行;首尾空白与多余空行去掉。
248
+ text = text
249
+ .split('\n')
250
+ .map((line) => line.replace(/[^\S\n]+/g, ' ').trim())
251
+ .join('\n')
252
+ .replace(/\n{3,}/g, '\n\n')
253
+ .trim()
254
+ return text
209
255
  }
210
256
 
211
257
  /** 这个标签文本里是否含 markup(用于 notes)。*/
@@ -345,7 +391,7 @@ function scanCells(source, from, to) {
345
391
  }
346
392
  // `<mxPoint as="offset"/>`:drawio 存"标签相对于那个点的残余偏移"用的。
347
393
  // 没有 x/y 属性时按 (0,0) 处理。
348
- const offsetMatch = /<mxPoint\b([^>]*?)as\s*=\s*"offset"[^>]*?\/>/.exec(geoInnerText)
394
+ const offsetMatch = Array.from(geoInnerText.matchAll(/<mxPoint\b([^>]*?)\/>/g)).find((match) => attr(match[1], 'as') === 'offset') || null
349
395
  const offset =
350
396
  offsetMatch === null
351
397
  ? { x: 0, y: 0 }
@@ -783,6 +829,10 @@ export function parseMxfile(text, options) {
783
829
  w: geo !== null && geo.width !== undefined ? geo.width : 120,
784
830
  h: geo !== null && geo.height !== undefined ? geo.height : 60,
785
831
  }
832
+ if (geo && geo.offset && (geo.offset.x !== 0 || geo.offset.y !== 0)) {
833
+ node.labelOffsetX = geo.offset.x
834
+ node.labelOffsetY = geo.offset.y
835
+ }
786
836
  if (cell.data !== undefined && cell.data !== null && Object.keys(cell.data).length > 0) node.data = cell.data
787
837
  // 属于哪个图层(沿 parent 往上找第一个图层):渲染时隐藏层要整层不画。
788
838
  const layerId = layerIdOf(info, cell)
@@ -960,10 +1010,13 @@ function nodeCellXml(node, parentId, indent) {
960
1010
  const y = Number.isFinite(Number(node.y)) ? Number(node.y) : 0
961
1011
  const w = Number.isFinite(Number(node.w)) ? Number(node.w) : 120
962
1012
  const h = Number.isFinite(Number(node.h)) ? Number(node.h) : 60
1013
+ const offset = nodeLabelOffset(node)
1014
+ const head = indent + ' <mxGeometry x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" as="geometry"'
1015
+ const geometry = offset.x === 0 && offset.y === 0 ? head + ' />' : head + '><mxPoint x="' + offset.x + '" y="' + offset.y + '" as="offset" /></mxGeometry>'
963
1016
  return withDataWrapper(
964
1017
  [
965
1018
  indent + '<mxCell id="' + escapeAttr(String(node.id)) + '"' + label + style + ' vertex="1" parent="' + escapeAttr(parentId) + '">',
966
- indent + ' <mxGeometry x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" as="geometry" />',
1019
+ geometry,
967
1020
  indent + '</mxCell>',
968
1021
  ],
969
1022
  node,
@@ -1137,6 +1190,23 @@ function wrapAsObject(cell, text, data) {
1137
1190
  return '<object label="' + escapeAttr(labelFromValue(cell.label)) + '"' + attrs + ' id="' + escapeAttr(String(cell.id)) + '">' + inner + '</object>'
1138
1191
  }
1139
1192
 
1193
+ function nodeLabelOffset(node) {
1194
+ return { x: Number.isFinite(node.labelOffsetX) ? node.labelOffsetX : 0, y: Number.isFinite(node.labelOffsetY) ? node.labelOffsetY : 0 }
1195
+ }
1196
+
1197
+ /** 仅修改顶点标签的原生 offset 点,保留折叠尺寸等未知几何子元素。 */
1198
+ function writeNodeLabelOffset(text, cell, offset) {
1199
+ const point = offset.x === 0 && offset.y === 0 ? '' : '<mxPoint x="' + offset.x + '" y="' + offset.y + '" as="offset" />'
1200
+ if (!cell.geo) return appendGeometry(text, cell, '<mxGeometry as="geometry">' + point + '</mxGeometry>')
1201
+ const geo = cell.geo
1202
+ if (geo.selfClosing) return text.slice(0, geo.start) + '<mxGeometry' + text.slice(geo.attrsStart, geo.attrsEnd) + '>' + point + '</mxGeometry>' + text.slice(geo.end)
1203
+ const inner = text.slice(geo.innerFrom, geo.innerTo)
1204
+ const match = Array.from(inner.matchAll(/<mxPoint\b([^>]*?)\/>/g)).find((item) => attr(item[1], 'as') === 'offset')
1205
+ const replacement = match && point ? setAttrsIn(match[0], '<mxPoint'.length, match[0].length - 2, { x: String(offset.x), y: String(offset.y) }) : point
1206
+ const next = match ? inner.slice(0, match.index) + replacement + inner.slice(match.index + match[0].length) : inner + point
1207
+ return text.slice(0, geo.innerFrom) + next + text.slice(geo.innerTo)
1208
+ }
1209
+
1140
1210
  /** 改写一个顶点单元:标签、style、几何(几何要写回**相对父级**的坐标)。 */
1141
1211
  function rebuildNodeCell(info, cell, node, chain) {
1142
1212
  // 单元跨度是**相对页体(mxGraphModel)**的,不是相对整份文件 —— 切错参照系会把
@@ -1154,6 +1224,12 @@ function rebuildNodeCell(info, cell, node, chain) {
1154
1224
  subject = fresh
1155
1225
  }
1156
1226
  }
1227
+ const wantLabelOffset = nodeLabelOffset(node)
1228
+ const fileLabelOffset = subject.geometry && subject.geometry.offset || { x: 0, y: 0 }
1229
+ if (wantLabelOffset.x !== fileLabelOffset.x || wantLabelOffset.y !== fileLabelOffset.y) {
1230
+ original = writeNodeLabelOffset(original, subject, wantLabelOffset)
1231
+ subject = rescanCell(original) || subject
1232
+ }
1157
1233
  const changes = []
1158
1234
  const label = typeof node.label === 'string' ? node.label : ''
1159
1235
  if (label !== labelFromValue(subject.label)) {
@@ -0,0 +1,66 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ import { mkdirSync, writeFileSync, renameSync, readdirSync, rmSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { randomUUID, createHash } from 'node:crypto'
5
+ import { cpus, totalmem } from 'node:os'
6
+ import { monitorEventLoopDelay } from 'node:perf_hooks'
7
+ import { createPerformanceMetrics } from './performance-metrics.js'
8
+
9
+ let environment
10
+ function environmentInfo() {
11
+ if (!environment) {
12
+ const cpu = cpus()
13
+ let sourceSha256 = null
14
+ try { sourceSha256 = createHash('sha256').update(['trace-image.js', 'raster.js', 'video-trace-worker.js', 'video-trace-pool.js', 'video-trace-reuse.js', 'video-export.js']
15
+ .map((file) => readFileSync(new URL(file, import.meta.url))).join('')).digest('hex') } catch {}
16
+ environment = { node: process.version, platform: process.platform, arch: process.arch, cpu: cpu[0]?.model,
17
+ logicalCpus: cpu.length, totalMemoryBytes: totalmem(), sourceSha256 }
18
+ }
19
+ return environment
20
+ }
21
+
22
+ // Diagnostics must never change the outcome of a drawing/export task.
23
+ export function createPerformanceLog(root, kind, metadata = {}, options = {}) {
24
+ const enabled = options.enabled ?? process.env.DRAWAI_PERF === '1'
25
+ const metrics = createPerformanceMetrics(), id = randomUUID(), startedAt = new Date().toISOString()
26
+ const cpuStart = enabled ? process.cpuUsage() : null
27
+ let finished = false, report = null, timer, delay
28
+ function sample() {
29
+ if (!enabled) return
30
+ const memory = process.memoryUsage()
31
+ metrics.peak('hostRssBytes', memory.rss); metrics.peak('hostHeapUsedBytes', memory.heapUsed)
32
+ }
33
+ if (enabled) {
34
+ sample(); timer = setInterval(sample, 250); timer.unref?.()
35
+ delay = monitorEventLoopDelay({ resolution: 20 }); delay.enable()
36
+ }
37
+ return {
38
+ enabled, id,
39
+ start(name) { return enabled ? metrics.start(name) : () => {} },
40
+ add(name, ms) { if (enabled) metrics.add(name, ms) },
41
+ count(name, value) { if (enabled) metrics.count(name, value) },
42
+ peak(name, value) { if (enabled) metrics.peak(name, value) },
43
+ merge(prefix, value) { if (enabled) metrics.merge(prefix, value) },
44
+ snapshot() { sample(); return enabled ? metrics.snapshot() : undefined },
45
+ finish(status, extra = {}) {
46
+ if (!enabled || finished) return report
47
+ finished = true; clearInterval(timer); sample(); delay.disable()
48
+ const cpu = process.cpuUsage(cpuStart)
49
+ metrics.count('hostCpuUserMs', cpu.user / 1000); metrics.count('hostCpuSystemMs', cpu.system / 1000)
50
+ if (Number.isFinite(delay.max)) metrics.peak('hostEventLoopDelayMaxMs', delay.max / 1e6)
51
+ report = { schemaVersion: 1, id, kind, status, startedAt, environment: environmentInfo(), metadata: { ...metadata, ...extra }, ...metrics.snapshot(),
52
+ resources: { intervalMs: 250, rssScope: 'sampled host process including Workers; excludes FFmpeg/browser',
53
+ ffmpegPeakRssBytes: null, browserPeakRssBytes: null } }
54
+ try {
55
+ const dir = join(root, '.drawai-cache', 'performance')
56
+ mkdirSync(dir, { recursive: true })
57
+ const name = startedAt.replace(/[:.]/g, '-') + '-' + id + '.json', dest = join(dir, name)
58
+ writeFileSync(dest + '.tmp', JSON.stringify(report, null, 2)); renameSync(dest + '.tmp', dest)
59
+ // Keep at most 100 reports, never traverse media directories or retain source data.
60
+ const names = readdirSync(dir).filter((entry) => /^\d{4}-.*-[0-9a-f-]{36}\.json$/.test(entry)).sort()
61
+ for (const entry of names.slice(0, Math.max(0, names.length - 100))) rmSync(join(dir, entry), { force: true })
62
+ } catch { /* Unwritable diagnostics are non-fatal. */ }
63
+ return report
64
+ },
65
+ }
66
+ }
@@ -0,0 +1,47 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ // Bounded aggregates shared by browser, host and Workers. Durations may overlap.
3
+ export function createPerformanceMetrics(clock = () => performance.now()) {
4
+ const started = clock(), stages = Object.create(null), counters = Object.create(null), peaks = Object.create(null)
5
+ function add(name, ms, count = 1, max = ms) {
6
+ if (!/^[a-zA-Z][\w.-]{0,79}$/.test(name) || !Number.isFinite(ms) || ms < 0 || !Number.isSafeInteger(count) || count < 1 || !Number.isFinite(max) || max < 0) return
7
+ if (!stages[name] && Object.keys(stages).length >= 96) return
8
+ const stage = stages[name] || (stages[name] = { count: 0, totalMs: 0, maxMs: 0 })
9
+ stage.count += count; stage.totalMs += ms; stage.maxMs = Math.max(stage.maxMs, max)
10
+ }
11
+ return {
12
+ add,
13
+ start(name) { const at = clock(); let ended = false; return () => { if (!ended) { ended = true; add(name, clock() - at) } } },
14
+ count(name, value = 1) { if (/^[a-zA-Z][\w.-]{0,79}$/.test(name) && Number.isFinite(value) && value >= 0 && (name in counters || Object.keys(counters).length < 96)) counters[name] = (counters[name] || 0) + value },
15
+ peak(name, value) { if (/^[a-zA-Z][\w.-]{0,79}$/.test(name) && Number.isFinite(value) && value >= 0 && (name in peaks || Object.keys(peaks).length < 32)) peaks[name] = Math.max(peaks[name] || 0, value) },
16
+ merge(prefix, report) {
17
+ // Only numeric, bounded aggregates cross the trust boundary; never copy raw objects/paths.
18
+ for (const [name, stage] of Object.entries(report?.stages || {}).slice(0, 96)) {
19
+ if (stage && typeof stage === 'object') add(prefix + name, stage.totalMs, stage.count, stage.maxMs)
20
+ }
21
+ for (const [name, value] of Object.entries(report?.counters || {}).slice(0, 96)) this.count(prefix + name, value)
22
+ for (const [name, value] of Object.entries(report?.peaks || {}).slice(0, 32)) this.peak(prefix + name, value)
23
+ },
24
+ snapshot() { return JSON.parse(JSON.stringify({ wallMs: Math.max(0, clock() - started), stages, counters, peaks })) },
25
+ }
26
+ }
27
+
28
+ export function createMediaPerformance() {
29
+ const metrics = createPerformanceMetrics()
30
+ let observer
31
+ function accept(entries) {
32
+ for (const entry of entries) { metrics.add('longTask', entry.duration); metrics.count('longTasks') }
33
+ }
34
+ return {
35
+ ...metrics,
36
+ enable(enabled) {
37
+ if (!enabled || observer || typeof PerformanceObserver === 'undefined') return
38
+ try { observer = new PerformanceObserver((list) => accept(list.getEntries())); observer.observe({ entryTypes: ['longtask'] }) }
39
+ catch { observer?.disconnect(); observer = null }
40
+ },
41
+ async measure(name, action) { const done = metrics.start(name); try { return await action() } finally { done() } },
42
+ report() {
43
+ if (observer) { accept(observer.takeRecords()); observer.disconnect(); observer = null }
44
+ return metrics.snapshot()
45
+ },
46
+ }
47
+ }
package/lib/raster.js CHANGED
@@ -107,7 +107,8 @@ export function decodePng(bytes) {
107
107
  let interlace = 0
108
108
  let palette = null // Buffer(RGB 三元组)
109
109
  let paletteAlpha = null // Buffer(每索引一个 alpha)
110
- let trnsGray = null // 灰度/RGB 的单色透明键(这里只用于灰度)
110
+ let trnsGray = null // 原始位深下的透明键,必须在转换成 RGBA8 之前比较
111
+ let trnsRgb = null
111
112
  const idat = []
112
113
 
113
114
  let offset = 8
@@ -131,6 +132,9 @@ export function decodePng(bytes) {
131
132
  } else if (type === 'tRNS') {
132
133
  if (colorType === 3) paletteAlpha = Buffer.from(chunk)
133
134
  else if (colorType === 0 && chunk.length >= 2) trnsGray = (chunk[0] << 8) | chunk[1]
135
+ else if (colorType === 2 && chunk.length >= 6) {
136
+ trnsRgb = [(chunk[0] << 8) | chunk[1], (chunk[2] << 8) | chunk[3], (chunk[4] << 8) | chunk[5]]
137
+ }
134
138
  } else if (type === 'IDAT') {
135
139
  idat.push(Buffer.from(chunk))
136
140
  } else if (type === 'IEND') {
@@ -175,16 +179,22 @@ export function decodePng(bytes) {
175
179
  } else if (colorType === 0 || colorType === 4) {
176
180
  // 灰度:位深 1/2/4/8/16,可带 alpha
177
181
  let v
178
- if (bitDepth === 8) v = samples[col * spp]
179
- else if (bitDepth === 16) v = samples[col * spp * 2] // 取高字节(8 位精度足够)
182
+ let rawGray
183
+ if (bitDepth === 8) v = rawGray = samples[col * spp]
184
+ else if (bitDepth === 16) {
185
+ const o = col * spp * 2
186
+ rawGray = (samples[o] << 8) | samples[o + 1]
187
+ v = rawGray >>> 8
188
+ }
180
189
  else {
181
190
  const perByte = 8 / bitDepth
182
191
  const byte = samples[Math.floor(col / perByte)]
183
192
  const shift = 8 - bitDepth * ((col % perByte) + 1)
184
193
  const raw0 = (byte >> shift) & ((1 << bitDepth) - 1)
194
+ rawGray = raw0
185
195
  v = Math.round((raw0 * 255) / ((1 << bitDepth) - 1)) // 1/2/4 位要拉伸到 0..255
186
196
  }
187
- if (trnsGray !== null && bitDepth === 8 && v === (trnsGray & 0xff)) a = 0
197
+ if (colorType === 0 && trnsGray !== null && rawGray === trnsGray) a = 0
188
198
  r = v
189
199
  g = v
190
200
  b = v
@@ -202,6 +212,13 @@ export function decodePng(bytes) {
202
212
  b = samples[col * spp + 2]
203
213
  if (colorType === 6) a = samples[col * spp + 3]
204
214
  }
215
+ if (colorType === 2 && trnsRgb !== null) {
216
+ const o = col * pixelBytes
217
+ const rawRgb = bitDepth === 16
218
+ ? [(samples[o] << 8) | samples[o + 1], (samples[o + 2] << 8) | samples[o + 3], (samples[o + 4] << 8) | samples[o + 5]]
219
+ : [r, g, b]
220
+ if (rawRgb.every((v, i) => v === trnsRgb[i])) a = 0
221
+ }
205
222
  }
206
223
  const o = (y * width + x) * 4
207
224
  out[o] = r
@@ -94,6 +94,10 @@ var NODE_SHAPE_STYLE = {
94
94
  // image:内嵌位图(png/jpeg/gif/webp 的 data URI)。落盘同样是 drawio 的图片形状;
95
95
  // 缺省 imageAspect=1(等比居中)—— 截图/照片拉伸会变形,和 svg 图标的缺省相反。
96
96
  image: 'shape=image;imageAspect=1',
97
+ // video:画布上的统一播放器节点。落盘仍是 shape=image + 海报 SVG;真实元数据在
98
+ // `<object>` 的 drawaiMedia / mediaClip 上(旁路 `*.media/<clip>/`)。style 层面与 svg 同构,
99
+ // 识别视频靠 data,不靠 style 键 —— 这样 drawio 桌面版也能看见海报静帧。
100
+ video: 'shape=image;imageAspect=0',
97
101
  // 分区(飞书式区块 / drawio 的 group 容器):可插入的容器元素,不是"选中后成组"。
98
102
  // verticalAlign=top + align=left:标题贴在框顶部左侧(区块名),里面再放其它节点。
99
103
  section:
@@ -101,18 +105,43 @@ var NODE_SHAPE_STYLE = {
101
105
  }
102
106
 
103
107
  /** 形状枚举的顺序(UI 菜单、read 输出的稳定顺序)。顺序对齐 drawio 的形状面板。 */
104
- var NODE_SHAPES = ['rect', 'rounded', 'text', 'section', 'stadium', 'ellipse', 'diamond', 'parallelogram', 'cylinder', 'document', 'hexagon', 'svg', 'image']
108
+ var NODE_SHAPES = ['rect', 'rounded', 'text', 'section', 'stadium', 'ellipse', 'diamond', 'parallelogram', 'cylinder', 'document', 'hexagon', 'svg', 'image', 'video']
109
+
110
+ /** 视频节点缺省海报(播放按钮静帧)。 */
111
+ var DEFAULT_VIDEO_POSTER =
112
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 90">' +
113
+ '<rect width="160" height="90" fill="#1f1f1f"/>' +
114
+ '<circle cx="80" cy="45" r="20" fill="#ffffff" fill-opacity="0.92"/>' +
115
+ '<path d="M74 35 L74 55 L94 45 Z" fill="#1f1f1f"/>' +
116
+ '</svg>'
105
117
 
106
118
  /** 文字形状独有的"隐形"设置:离开文字形状时要清掉(它们不该跟着变成别的形状)。 */
107
119
  var TEXT_SHAPE_ONLY_KEYS = { strokeColor: 'none', fillColor: 'none' }
108
120
 
109
- /** SVG / 位图形状独有的键:离开这类形状时要清掉(否则文件里留着一大段看不见的 data URI)。 */
121
+ /** SVG / 位图 / 视频海报形状独有的键:离开这类形状时要清掉(否则文件里留着一大段看不见的 data URI)。 */
110
122
  var SVG_SHAPE_ONLY_KEYS = ['image', 'imageAspect']
111
123
  var IMAGE_SHAPE_ONLY_KEYS = SVG_SHAPE_ONLY_KEYS
124
+ var VIDEO_SHAPE_ONLY_KEYS = SVG_SHAPE_ONLY_KEYS
125
+
126
+ /** 节点是否为视频播放器(看 `<object>` 数据,不看 style)。 */
127
+ function isVideoNodeData(data) {
128
+ if (data === null || data === undefined || typeof data !== 'object') return false
129
+ var v = data.drawaiMedia
130
+ return v === '1' || v === 1 || v === true
131
+ }
132
+
133
+ /** 报告/UI 用的形状名:视频优先于 style 推导的 svg/image。 */
134
+ function reportNodeShape(node) {
135
+ if (node !== null && node !== undefined && typeof node === 'object' && isVideoNodeData(node.data)) return 'video'
136
+ return nodeShapeFromStyle(node && typeof node.style === 'string' ? node.style : '')
137
+ }
112
138
 
113
139
  /** 分区形状独有的键:离开 section 时要清掉(否则普通矩形仍带着 container=1)。 */
114
140
  var SECTION_SHAPE_ONLY_KEYS = ['group', 'container', 'collapsible', 'recursiveResize', 'spacingLeft', 'spacingTop', 'fontStyle']
115
141
 
142
+ /** swimlane 独有的键:离开时清掉,避免普通矩形仍被认成泳道。 */
143
+ var SWIMLANE_SHAPE_ONLY_KEYS = ['swimlane', 'startSize', 'swimlaneFillColor', 'horizontal', 'collapsible']
144
+
116
145
  /** 线型枚举 → style 键。solid 是缺省,落盘即"没有 dashed 键"。 */
117
146
  var DASH_PRESETS = {
118
147
  solid: null,
@@ -293,9 +322,11 @@ function styleIsEmpty(style) {
293
322
  function nodeShapeFromStyle(style) {
294
323
  // 独立文字:drawio 用裸键 `text`(不是 `shape=text`)表达,所以要单独认一次。
295
324
  if (styleGet(style, 'text', null) !== null) return 'text'
325
+ // swimlane:标题带容器(startSize);优先于 container=1 的分区判定。
326
+ if (styleGet(style, 'swimlane', null) !== null || styleGet(style, 'shape', null) === 'swimlane') return 'swimlane'
296
327
  // 分区 / 分组容器:drawio 用裸键 `group` + `container=1`(不是 shape=group)。
297
328
  if (styleGet(style, 'group', null) !== null) return 'section'
298
- if (styleGet(style, 'container', null) === '1' && styleGet(style, 'swimlane', null) === null) return 'section'
329
+ if (styleGet(style, 'container', null) === '1') return 'section'
299
330
  var shape = styleGet(style, 'shape', null)
300
331
  // 图片形状:`shape=image`(或 drawio 的裸键写法留下的 `image=` data URI)。
301
332
  // · SVG data URI → 工具形状 `svg`(可编辑标记)
@@ -335,6 +366,9 @@ function styleWithNodeShape(style, shape) {
335
366
  var wasSvg = nodeShapeFromStyle(style) === 'svg'
336
367
  var wasImage = nodeShapeFromStyle(style) === 'image'
337
368
  var wasSection = nodeShapeFromStyle(style) === 'section'
369
+ var wasSwimlane = nodeShapeFromStyle(style) === 'swimlane'
370
+ // video 在 style 层与 svg 同构(都是 shape=image + image=);换离视频时清海报键。
371
+ var target = shape === 'video' ? 'svg' : shape
338
372
  var shapeKeys = ['shape', 'ellipse', 'rhombus', 'rounded', 'arcSize', 'text']
339
373
  for (var i = 0; i < shapeKeys.length; i += 1) delete map[shapeKeys[i]]
340
374
  // 从"文字"换成别的形状:把它的隐形设置也清掉,否则换出来的矩形/椭圆是看不见的
@@ -345,20 +379,23 @@ function styleWithNodeShape(style, shape) {
345
379
  if (String(map[noneKeys[n]]).toLowerCase() === TEXT_SHAPE_ONLY_KEYS[noneKeys[n]]) delete map[noneKeys[n]]
346
380
  }
347
381
  }
348
- // 同理:从"SVG 图形 / 位图"换成别的形状时,把那段内嵌 data URI 一起清掉 —— 留着它
349
- // 没人看得见,却会让文件白白胖一大截(而且下次换回会突然冒出一张旧图)。
350
- // svg ↔ image 互切也要清:一个是标记、一个是位图,不能共用同一段 image=。
351
- if ((wasSvg || wasImage) && shape !== 'svg' && shape !== 'image') {
382
+ // 同理:从"SVG 图形 / 位图 / 视频海报"换成别的形状时,把那段内嵌 data URI 一起清掉。
383
+ // video 在 style 层等同 svg(都是 image + svg data URI);svg ↔ image 互切也要清。
384
+ var keepImage = target === 'svg' || target === 'image' || shape === 'video'
385
+ if ((wasSvg || wasImage) && keepImage === false) {
352
386
  for (var s = 0; s < SVG_SHAPE_ONLY_KEYS.length; s += 1) delete map[SVG_SHAPE_ONLY_KEYS[s]]
353
- } else if (wasSvg && shape === 'image') {
387
+ } else if (wasSvg && target === 'image') {
354
388
  for (var s2 = 0; s2 < SVG_SHAPE_ONLY_KEYS.length; s2 += 1) delete map[SVG_SHAPE_ONLY_KEYS[s2]]
355
- } else if (wasImage && shape === 'svg') {
389
+ } else if (wasImage && (target === 'svg' || shape === 'video')) {
356
390
  for (var s3 = 0; s3 < IMAGE_SHAPE_ONLY_KEYS.length; s3 += 1) delete map[IMAGE_SHAPE_ONLY_KEYS[s3]]
357
391
  }
358
392
  // 离开分区:清掉容器键,否则普通矩形还会带着 container=1,被当成分区。
359
393
  if (wasSection && shape !== 'section') {
360
394
  for (var c = 0; c < SECTION_SHAPE_ONLY_KEYS.length; c += 1) delete map[SECTION_SHAPE_ONLY_KEYS[c]]
361
395
  }
396
+ if (wasSwimlane && shape !== 'swimlane') {
397
+ for (var sw = 0; sw < SWIMLANE_SHAPE_ONLY_KEYS.length; sw += 1) delete map[SWIMLANE_SHAPE_ONLY_KEYS[sw]]
398
+ }
362
399
  var preset = parseStyle(styleFromNodeShape(shape))
363
400
  var names = Object.keys(preset)
364
401
  for (var p = 0; p < names.length; p += 1) map[names[p]] = preset[names[p]]
@@ -920,6 +957,8 @@ function normalizeDrawioDoc(raw) {
920
957
  out.y = Number.isFinite(Number(rawNode.y)) ? Number(rawNode.y) : 0
921
958
  out.w = Number.isFinite(Number(rawNode.w)) ? Number(rawNode.w) : 0
922
959
  out.h = Number.isFinite(Number(rawNode.h)) ? Number(rawNode.h) : 0
960
+ if (Number.isFinite(rawNode.labelOffsetX)) out.labelOffsetX = rawNode.labelOffsetX
961
+ if (Number.isFinite(rawNode.labelOffsetY)) out.labelOffsetY = rawNode.labelOffsetY
923
962
  // 编辑数据(drawio 用户对象上的自定义属性):只收字符串值,原样带上。
924
963
  var data = rawNode.data
925
964
  if (isObject(data)) out.data = copyData(data)
@@ -1011,9 +1050,9 @@ function normalizeDrawioDoc(raw) {
1011
1050
 
1012
1051
  export {
1013
1052
  STYLE_KEY_ORDER, PALETTE, NODE_SHAPES, NODE_SHAPE_STYLE, DASH_KINDS, ARROW_KINDS, SIDES,
1014
- DEFAULT_FILL, DEFAULT_STROKE, DEFAULT_FONT, DEFAULT_NODE_STYLE, DEFAULT_EDGE_STYLE,
1053
+ DEFAULT_FILL, DEFAULT_STROKE, DEFAULT_FONT, DEFAULT_NODE_STYLE, DEFAULT_EDGE_STYLE, DEFAULT_VIDEO_POSTER,
1015
1054
  parseStyle, formatStyle, styleGet, styleSet, stylePatch, styleIsEmpty, styleNumber,
1016
- nodeShapeFromStyle, styleFromNodeShape, styleWithNodeShape,
1055
+ nodeShapeFromStyle, styleFromNodeShape, styleWithNodeShape, isVideoNodeData, reportNodeShape,
1017
1056
  isSvgMarkup, normalizeSvgMarkup, isSvgDataUri, isRasterDataUri, svgMarkupFromImageValue, imageValueFromSvgMarkup,
1018
1057
  imageValueFromStyle, svgMarkupFromStyle, styleWithSvgMarkup, styleWithoutSvgMarkup,
1019
1058
  styleWithImageDataUri, styleWithoutImageDataUri,
@@ -1028,5 +1067,6 @@ export {
1028
1067
  avoidFromStyle, styleWithAvoid,
1029
1068
  normalizePoint, normalizePoints, pointsEqual,
1030
1069
  edgeTerminalId, edgeFreePoint,
1070
+ copyData,
1031
1071
  normalizeDrawioDoc, snapDocGeometry,
1032
1072
  }