dsh-drawai 0.2.7 → 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/README.md +51 -9
- package/lib/client.js +3764 -461
- package/lib/index.js +1282 -40
- package/lib/media.js +661 -0
- package/lib/mxfile.js +88 -12
- package/lib/performance-log.js +66 -0
- package/lib/performance-metrics.js +47 -0
- package/lib/raster.js +21 -4
- package/lib/style-kernel.js +51 -11
- package/lib/trace-image.js +595 -106
- package/lib/video-export.js +193 -0
- package/lib/video-trace-pool.js +119 -0
- package/lib/video-trace-reuse.js +49 -0
- package/lib/video-trace-worker.js +59 -0
- package/package.json +3 -2
package/lib/mxfile.js
CHANGED
|
@@ -53,16 +53,44 @@ export const META_ID = 'drawai-meta'
|
|
|
53
53
|
|
|
54
54
|
// ---- 文本与属性小工具(mxfile 的属性/文本足够用,不引入 XML 依赖)-----------
|
|
55
55
|
|
|
56
|
-
/**
|
|
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(/&
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
*
|
|
225
|
+
* 我们按纯文本渲染标签,而 drawio 的标签常带 HTML(`html=1` + `<br>`、`<b>`、表格…)。
|
|
226
|
+
* 折算规则(有损但可读):
|
|
227
|
+
* · `<br>` / 块级收尾标签 → 换行;
|
|
228
|
+
* · `<td>` / `<th>` 之间留空格(表格粗折算成列);
|
|
229
|
+
* · 其余标签剥掉;命名实体(含 ` `)解码;
|
|
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
|
-
|
|
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
|
-
|
|
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([^>]*?)
|
|
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
|
-
|
|
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 //
|
|
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
|
-
|
|
179
|
-
|
|
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 (
|
|
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
|
package/lib/style-kernel.js
CHANGED
|
@@ -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 /
|
|
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'
|
|
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
|
-
|
|
351
|
-
if ((wasSvg || wasImage) &&
|
|
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 &&
|
|
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 &&
|
|
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
|
}
|