dsh-drawai 0.1.0 → 0.2.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
@@ -39,7 +39,7 @@ import { createHash } from 'node:crypto'
39
39
  import { deflateRawSync, inflateRawSync, inflateSync } from 'node:zlib'
40
40
  // style 的"真相"只有一份(内核):这里只借它做**语义比较**,
41
41
  // 免得"文档层规范化过的 style 串"和"文件里原样的串"逐字不同,就把没动过的单元重写一遍。
42
- import { DEFAULT_EDGE_STYLE, formatStyle } from './style-kernel.js'
42
+ import { DEFAULT_EDGE_STYLE, formatStyle, isRasterDataUri, isSvgDataUri, styleGet } from './style-kernel.js'
43
43
 
44
44
  /** 画布默认页尺寸(新建文件时写进 mxGraphModel,drawio 缺省与此一致:A4 竖版 × 100 dpi)。 */
45
45
  const PAGE_W = 850
@@ -429,10 +429,17 @@ function scanCells(source, from, to) {
429
429
  * 全文分析。**解析与写回共用同一份结构** —— 两边对"哪些单元属于我们"必须完全一致,
430
430
  * 否则写回会把我们不认识的单元当成"用户删掉的"删掉。
431
431
  */
432
- function analyzeMxfile(source) {
432
+ function analyzeMxfile(source, pageIndex) {
433
433
  const diagrams = [...source.matchAll(/<diagram\b([^>]*?)(\/>|>)([\s\S]*?)(?:<\/diagram>|$)/g)]
434
434
  if (diagrams.length === 0) throw new Error('这不是 mxfile:找不到 <diagram> 元素')
435
- const first = diagrams[0]
435
+ const pageNames = diagrams.map((d, i) => {
436
+ const name = attr(d[1], 'name')
437
+ return typeof name === 'string' && name.length > 0 ? name : '第 ' + (i + 1) + ' 页'
438
+ })
439
+ let idx = Number(pageIndex)
440
+ if (Number.isFinite(idx) === false || idx < 0) idx = 0
441
+ if (idx >= diagrams.length) idx = diagrams.length - 1
442
+ const first = diagrams[idx]
436
443
  const closed = first[0].endsWith('</diagram>')
437
444
  const bodyEnd = first.index + first[0].length - (closed ? '</diagram>'.length : 0)
438
445
  const bodyStart = bodyEnd - first[3].length
@@ -477,6 +484,8 @@ function analyzeMxfile(source) {
477
484
  return {
478
485
  source: source,
479
486
  pageCount: diagrams.length,
487
+ pageIndex: idx,
488
+ pageNames: pageNames,
480
489
  // 纸张尺寸(drawio 挂在 `mxGraphModel` 上的属性):AI 做布局/导出建议时要看它 ——
481
490
  // 850×1100 是 drawio 的 A4 竖版缺省,但不代表用户这份文件就是那个尺寸。
482
491
  pageSize: readPageSize(modelXml),
@@ -598,7 +607,12 @@ function layerOfParent(info, cellOrId) {
598
607
  *
599
608
  * 返回的 offset 是"沿父链累加的相对坐标总量",于是写入值 = 绝对坐标 − offset。
600
609
  */
601
- function writeChainOf(info, modelAbs, deletedIds, cell, wantLayer, knownLayers) {
610
+ function writeChainOf(info, modelAbs, deletedIds, cell, wantLayer, knownLayers, modelParent) {
611
+ // ⓪ 模型显式写了容器 parent(成组):优先用它,相对坐标 = 绝对 − 容器绝对。
612
+ if (typeof modelParent === 'string' && modelParent.length > 0 && modelAbs.has(modelParent) && deletedIds.has(modelParent) === false) {
613
+ const abs = modelAbs.get(modelParent)
614
+ return { parentId: modelParent, offset: { x: abs.x, y: abs.y } }
615
+ }
602
616
  // ① 先按文件里的父链算出"本来要写到哪个父级、偏移多少"(容器层级靠这一步保住)。
603
617
  let parentId = cell.parent
604
618
  let offset = { x: 0, y: 0 }
@@ -624,6 +638,11 @@ function writeChainOf(info, modelAbs, deletedIds, cell, wantLayer, knownLayers)
624
638
  parentId = layerOrRoot(info)
625
639
  offset = { x: 0, y: 0 }
626
640
  }
641
+ // 模型要求清掉容器 parent(取消组合):挂回图层。
642
+ if (modelParent === null && info.layerIds.has(parentId) === false && parentId !== info.rootId) {
643
+ const layer = typeof wantLayer === 'string' && knownLayers.has(wantLayer) ? wantLayer : layerOrRoot(info)
644
+ return { parentId: layer, offset: { x: 0, y: 0 } }
645
+ }
627
646
  // ② 模型显式要求的层与文件这一支的层不一致 → 换父级(单元被显式移层时它离开原容器)。
628
647
  const want = typeof wantLayer === 'string' && wantLayer.length > 0 && knownLayers.has(wantLayer) ? wantLayer : null
629
648
  if (want !== null && layerOfParent(info, parentId) !== want) return { parentId: want, offset: { x: 0, y: 0 } }
@@ -727,7 +746,8 @@ export function parseMxfile(text, options) {
727
746
  }
728
747
  if (source.trim().length === 0) throw new Error('.drawio 文件是空的')
729
748
 
730
- const info = analyzeMxfile(source)
749
+ const pageIndex = options !== undefined && options !== null ? options.page : 0
750
+ const info = analyzeMxfile(source, pageIndex)
731
751
  const owned = ownershipOf(info)
732
752
  const nodeIds = owned.nodeIds
733
753
  const pageCount = info.pageCount
@@ -746,7 +766,11 @@ export function parseMxfile(text, options) {
746
766
  if (cell.vertex !== true || owned.owned.has(String(cell.id)) === false) continue
747
767
  const isContainer = info.cells.some((c) => c.parent === cell.id && c !== cell)
748
768
  if (isContainer) containers += 1
749
- if (typeof cell.style === 'string' && /(^|;)image[;=]/.test(cell.style)) imageShapes += 1
769
+ // 内嵌 SVG / 内嵌位图都能画(见 style-kernel);只剩外链、相对路径才按矩形占位。
770
+ if (typeof cell.style === 'string' && /(^|;)image[;=]/.test(cell.style)) {
771
+ const imgVal = styleGet(cell.style, 'image', null)
772
+ if (isSvgDataUri(imgVal) === false && isRasterDataUri(imgVal) === false) imageShapes += 1
773
+ }
750
774
  if (hasMarkup(cell.label)) markupLabels += 1
751
775
  const offset = parentOffsetOf(info, cell)
752
776
  const geo = cell.geometry
@@ -821,9 +845,19 @@ export function parseMxfile(text, options) {
821
845
  edges.push(edge)
822
846
  }
823
847
 
824
- if (pageCount > 1) notes.push('这个文件有 ' + pageCount + ' 页,画布只显示/编辑第 1 页(其余页保存时原样保留)')
825
- if (containers > 0) notes.push(containers + ' 个分组/容器:画布按绝对位置显示,文件里的父子层级照旧保留')
826
- if (imageShapes > 0) notes.push(imageShapes + ' 个图片/自定义形状按矩形显示(本画布不画图片,单元原样保留)')
848
+ if (pageCount > 1) {
849
+ notes.push(
850
+ '这个文件有 ' +
851
+ pageCount +
852
+ ' 页,当前编辑第 ' +
853
+ (info.pageIndex + 1) +
854
+ ' 页「' +
855
+ info.pageNames[info.pageIndex] +
856
+ '」(其它页保存时原样保留;视图菜单可切换)',
857
+ )
858
+ }
859
+ if (containers > 0) notes.push(containers + ' 个分组/容器:拖动容器会带走子节点;文件里的父子层级照旧保留')
860
+ if (imageShapes > 0) notes.push(imageShapes + ' 个外链/相对路径图片按矩形显示(内嵌位图与 SVG 能画;单元原样保留)')
827
861
  if (markupLabels > 0) notes.push(markupLabels + ' 个 HTML 标签按纯文本显示(改标签会写成纯文本)')
828
862
  if (dangling > 0) notes.push(dangling + ' 条边是悬空端(用 sourcePoint/targetPoint 保留)')
829
863
  if (kept > 0) notes.push(kept + ' 条边的两端都找不到落点:不显示,但原样保留在文件里')
@@ -885,10 +919,13 @@ export function parseMxfile(text, options) {
885
919
  }
886
920
  // 纸张尺寸:文件里写了才带(不写就是"这份文件没说",不是 850×1100)。
887
921
  if (info.pageSize !== null) doc.page = { w: info.pageSize.w, h: info.pageSize.h }
922
+ doc.meta.pageIndex = info.pageIndex
923
+ doc.meta.pageCount = pageCount
924
+ doc.meta.pageNames = info.pageNames
888
925
  if (existing !== null && existing.meta !== undefined && existing.meta !== null && existing.meta.pinned === true) {
889
926
  doc.meta.pinned = true
890
927
  }
891
- return { doc: doc, notes: notes, pageCount: pageCount }
928
+ return { doc: doc, notes: notes, pageCount: pageCount, pageIndex: info.pageIndex, pageNames: info.pageNames }
892
929
  }
893
930
 
894
931
  // ---- 单元的 XML 生成(新单元、以及从零生成一份文件时共用)--------------------
@@ -1242,9 +1279,15 @@ function rebuildEdgeCell(info, cell, edge, chain) {
1242
1279
  *
1243
1280
  * @returns { info, edits, dropped }
1244
1281
  */
1245
- function planDocEdits(originalText, doc) {
1282
+ function planDocEdits(originalText, doc, options) {
1246
1283
  const source = String(originalText === undefined || originalText === null ? '' : originalText)
1247
- const info = analyzeMxfile(source)
1284
+ const page =
1285
+ options !== undefined && options !== null && Number.isFinite(Number(options.page))
1286
+ ? Number(options.page)
1287
+ : doc !== null && doc.meta !== undefined && doc.meta !== null && Number.isFinite(Number(doc.meta.pageIndex))
1288
+ ? Number(doc.meta.pageIndex)
1289
+ : 0
1290
+ const info = analyzeMxfile(source, page)
1248
1291
  const owned = ownershipOf(info).owned
1249
1292
  const nodes = Array.isArray(doc.nodes) ? doc.nodes : []
1250
1293
  const edges = Array.isArray(doc.edges) ? doc.edges : []
@@ -1293,7 +1336,15 @@ function planDocEdits(originalText, doc) {
1293
1336
  if (owned.has(id)) edits.push({ start: cell.start, end: cell.end, text: '' })
1294
1337
  continue
1295
1338
  }
1296
- const chain = writeChainOf(info, modelAbs, deletedIds, cell, want.item.layer, knownLayers)
1339
+ const chain = writeChainOf(
1340
+ info,
1341
+ modelAbs,
1342
+ deletedIds,
1343
+ cell,
1344
+ want.item.layer,
1345
+ knownLayers,
1346
+ want.kind === 'node' ? (want.item.parent === undefined ? undefined : want.item.parent) : undefined,
1347
+ )
1297
1348
  if (want.kind === 'edge') {
1298
1349
  const anchors = anchorsOf(want.item)
1299
1350
  if (anchors === null) {
@@ -1489,7 +1540,7 @@ function planReorder(info, edits, nodes, edges, owned) {
1489
1540
  */
1490
1541
  export function applyDocToMxfile(originalText, doc, options) {
1491
1542
  const source = String(originalText === undefined || originalText === null ? '' : originalText)
1492
- const plan = planDocEdits(source, doc)
1543
+ const plan = planDocEdits(source, doc, options)
1493
1544
  const model = spliceEdits(plan.info.modelXml, plan.edits)
1494
1545
  const bodyText = plan.info.compressed ? compressModel(model) : model
1495
1546
  return {
@@ -1611,15 +1662,35 @@ export function buildMxfile(doc, options) {
1611
1662
  }
1612
1663
  if (layers.length === 0) lines.push(' <mxCell id="1" parent="0" />')
1613
1664
  const firstLayerId = layers.length > 0 ? String(layers[0].id) : '1'
1665
+ const nodeById = new Map()
1666
+ for (const node of nodes) {
1667
+ if (node !== null && typeof node === 'object' && typeof node.id === 'string') nodeById.set(String(node.id), node)
1668
+ }
1669
+ /** 新建文件时的 parent:有容器 parent 就挂容器(几何写相对坐标),否则挂图层。 */
1614
1670
  const parentOf = (cell) => {
1671
+ if (cell !== null && typeof cell === 'object' && typeof cell.parent === 'string' && cell.parent.length > 0 && nodeById.has(cell.parent)) {
1672
+ return cell.parent
1673
+ }
1615
1674
  const want = cell !== null && typeof cell === 'object' && typeof cell.layer === 'string' ? cell.layer : null
1616
1675
  if (want !== null && layers.some((l) => String(l.id) === want)) return want
1617
1676
  return firstLayerId
1618
1677
  }
1678
+ /** 容器子节点:画布存绝对坐标,写出时减掉父级绝对坐标。 */
1679
+ const relGeom = (node, parentId) => {
1680
+ const x = Number.isFinite(Number(node.x)) ? Number(node.x) : 0
1681
+ const y = Number.isFinite(Number(node.y)) ? Number(node.y) : 0
1682
+ const parent = nodeById.get(parentId)
1683
+ if (parent === undefined) return { x: x, y: y }
1684
+ const px = Number.isFinite(Number(parent.x)) ? Number(parent.x) : 0
1685
+ const py = Number.isFinite(Number(parent.y)) ? Number(parent.y) : 0
1686
+ return { x: x - px, y: y - py }
1687
+ }
1619
1688
  if (doc.meta !== undefined && doc.meta !== null && doc.meta.pinned === true) lines.push(metaCellXml(parentOf(doc.meta), ' ', doc.meta))
1620
1689
  for (const node of nodes) {
1621
1690
  if (node === null || typeof node !== 'object') continue
1622
- lines.push(nodeCellXml(node, parentOf(node), ' '))
1691
+ const pid = parentOf(node)
1692
+ const rel = relGeom(node, pid)
1693
+ lines.push(nodeCellXml(Object.assign({}, node, { x: rel.x, y: rel.y }), pid, ' '))
1623
1694
  }
1624
1695
  for (const edge of edges) {
1625
1696
  if (edge === null || typeof edge !== 'object') continue
package/lib/raster.js ADDED
@@ -0,0 +1,313 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ /**
3
+ * 位图解码 —— 把图片文件解成原始 RGBA 像素,供「逐像素矢量化」使用。
4
+ *
5
+ * 两条路:
6
+ * 1. **纯 Node 的 PNG 解码器**(零依赖,只用 node:zlib):PNG 是矢量化最常见的输入
7
+ * (截图、导出的角色图),自己解能保证"宿主装了什么依赖都能用"。
8
+ * 2. **可选 sharp**:非 PNG(JPEG / WebP / GIF…)自己没有解码器,装了 sharp 就用它。
9
+ * sharp 是可选依赖:没装时只报"这个格式解不开",不影响 PNG 那条主路。
10
+ *
11
+ * 解码结果是 `{ width, height, data }`,data 是 **RGBA8**(`width*height*4` 字节),
12
+ * 也就是矢量化的**唯一真相**:后面所有轮廓都从它算,中间不再碰文件。
13
+ */
14
+
15
+ import { inflateSync } from 'node:zlib'
16
+
17
+ /** PNG 的 8 字节签名。 */
18
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
19
+
20
+ /** Adam7 隔行扫描的 7 遍起始坐标与步长([x0, y0, dx, dy])。 */
21
+ const ADAM7 = [
22
+ [0, 0, 8, 8],
23
+ [4, 0, 8, 8],
24
+ [0, 4, 4, 8],
25
+ [2, 0, 4, 4],
26
+ [0, 2, 2, 4],
27
+ [1, 0, 2, 2],
28
+ [0, 1, 1, 2],
29
+ ]
30
+
31
+ /** PNG 里每个像素占几个样本(按颜色类型)。 */
32
+ function samplesPerPixel(colorType) {
33
+ if (colorType === 0) return 1 // 灰度
34
+ if (colorType === 2) return 3 // RGB
35
+ if (colorType === 3) return 1 // 调色板索引
36
+ if (colorType === 4) return 2 // 灰度 + alpha
37
+ if (colorType === 6) return 4 // RGBA
38
+ throw new Error('PNG colorType ' + colorType + ' 不支持')
39
+ }
40
+
41
+ function isPng(bytes) {
42
+ if (bytes.length < 8) return false
43
+ for (let i = 0; i < 8; i += 1) if (bytes[i] !== PNG_SIGNATURE[i]) return false
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * 解一条扫描线的过滤(PNG 规范 9.2)。
49
+ *
50
+ * bpp = 一个像素占的字节数(不是样本数 —— 过滤器按字节做,16 位样本也是 2 字节一组)。
51
+ * filters 是这条扫描线自己的类型字节(0..4),prev 是**已经解过滤**的上一条扫描线。
52
+ */
53
+ function unfilterLine(line, prev, filterType, bpp) {
54
+ const n = line.length
55
+ if (filterType === 0) return
56
+ if (filterType === 1) {
57
+ for (let i = bpp; i < n; i += 1) line[i] = (line[i] + line[i - bpp]) & 0xff
58
+ return
59
+ }
60
+ if (filterType === 2) {
61
+ if (prev === null) return
62
+ for (let i = 0; i < n; i += 1) line[i] = (line[i] + prev[i]) & 0xff
63
+ return
64
+ }
65
+ if (filterType === 3) {
66
+ for (let i = 0; i < n; i += 1) {
67
+ const left = i >= bpp ? line[i - bpp] : 0
68
+ const up = prev === null ? 0 : prev[i]
69
+ line[i] = (line[i] + ((left + up) >> 1)) & 0xff
70
+ }
71
+ return
72
+ }
73
+ if (filterType === 4) {
74
+ for (let i = 0; i < n; i += 1) {
75
+ const a = i >= bpp ? line[i - bpp] : 0
76
+ const b = prev === null ? 0 : prev[i]
77
+ const c = i >= bpp && prev !== null ? prev[i - bpp] : 0
78
+ // Paeth 预测器:取 a/b/c 里与 p=a+b-c 最接近的那个
79
+ const p = a + b - c
80
+ const pa = p > a ? p - a : a - p
81
+ const pb = p > b ? p - b : b - p
82
+ const pc = p > c ? p - c : c - p
83
+ const pred = pa <= pb && pa <= pc ? a : pb <= pc ? b : c
84
+ line[i] = (line[i] + pred) & 0xff
85
+ }
86
+ return
87
+ }
88
+ throw new Error('PNG 过滤器类型 ' + filterType + ' 不支持')
89
+ }
90
+
91
+ /**
92
+ * 解一份 PNG 成 RGBA8。
93
+ *
94
+ * 支持:颜色类型 0/2/3/4/6、位深 1/2/4/8/16、Adam7 隔行、tRNS 透明。
95
+ * 不支持(会明确报错):颜色类型 1(已废弃)。
96
+ */
97
+ export function decodePng(bytes) {
98
+ if (!isPng(bytes)) throw new Error('不是 PNG(签名不对)')
99
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
100
+
101
+ let width = 0
102
+ let height = 0
103
+ let bitDepth = 8
104
+ let colorType = 6
105
+ let interlace = 0
106
+ let palette = null // Buffer(RGB 三元组)
107
+ let paletteAlpha = null // Buffer(每索引一个 alpha)
108
+ let trnsGray = null // 灰度/RGB 的单色透明键(这里只用于灰度)
109
+ const idat = []
110
+
111
+ let offset = 8
112
+ while (offset + 8 <= bytes.length) {
113
+ const length = view.getUint32(offset)
114
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7])
115
+ const dataStart = offset + 8
116
+ const dataEnd = dataStart + length
117
+ if (dataEnd + 4 > bytes.length) throw new Error('PNG 数据块 "' + type + '" 越界(文件截断?)')
118
+ const chunk = bytes.subarray(dataStart, dataEnd)
119
+
120
+ if (type === 'IHDR') {
121
+ width = view.getUint32(dataStart)
122
+ height = view.getUint32(dataStart + 4)
123
+ bitDepth = bytes[dataStart + 8]
124
+ colorType = bytes[dataStart + 9]
125
+ interlace = bytes[dataStart + 12]
126
+ if (width <= 0 || height <= 0) throw new Error('PNG 尺寸非法:' + width + '×' + height)
127
+ } else if (type === 'PLTE') {
128
+ palette = Buffer.from(chunk)
129
+ } else if (type === 'tRNS') {
130
+ if (colorType === 3) paletteAlpha = Buffer.from(chunk)
131
+ else if (colorType === 0 && chunk.length >= 2) trnsGray = (chunk[0] << 8) | chunk[1]
132
+ } else if (type === 'IDAT') {
133
+ idat.push(Buffer.from(chunk))
134
+ } else if (type === 'IEND') {
135
+ break
136
+ }
137
+ offset = dataEnd + 4 // 跳过 CRC
138
+ }
139
+ if (width === 0 || height === 0) throw new Error('PNG 缺 IHDR')
140
+ if (idat.length === 0) throw new Error('PNG 缺 IDAT')
141
+ if (colorType === 3 && palette === null) throw new Error('调色板 PNG 缺 PLTE')
142
+
143
+ const spp = samplesPerPixel(colorType)
144
+ const raw = inflateSync(Buffer.concat(idat))
145
+ const out = Buffer.alloc(width * height * 4)
146
+
147
+ // 一个像素在扫描线里的字节数(16 位样本是 2 字节)
148
+ const pixelBytes = Math.ceil((spp * bitDepth) / 8)
149
+
150
+ /** 把一行已解过滤的样本写进 RGBA 输出。samples 是该行的原始字节。 */
151
+ function writeLine(samples, y, x0, dx, columns) {
152
+ for (let col = 0; col < columns; col += 1) {
153
+ const x = x0 + col * dx
154
+ let r = 255
155
+ let g = 255
156
+ let b = 255
157
+ let a = 255
158
+ if (colorType === 3) {
159
+ // 索引:位深 1/2/4/8
160
+ let index
161
+ if (bitDepth === 8) index = samples[col]
162
+ else {
163
+ const perByte = 8 / bitDepth
164
+ const byte = samples[Math.floor(col / perByte)]
165
+ const shift = 8 - bitDepth * ((col % perByte) + 1)
166
+ index = (byte >> shift) & ((1 << bitDepth) - 1)
167
+ }
168
+ const p = index * 3
169
+ r = palette[p]
170
+ g = palette[p + 1]
171
+ b = palette[p + 2]
172
+ if (paletteAlpha !== null && index < paletteAlpha.length) a = paletteAlpha[index]
173
+ } else if (colorType === 0 || colorType === 4) {
174
+ // 灰度:位深 1/2/4/8/16,可带 alpha
175
+ let v
176
+ if (bitDepth === 8) v = samples[col * spp]
177
+ else if (bitDepth === 16) v = samples[col * spp * 2] // 取高字节(8 位精度足够)
178
+ else {
179
+ const perByte = 8 / bitDepth
180
+ const byte = samples[Math.floor(col / perByte)]
181
+ const shift = 8 - bitDepth * ((col % perByte) + 1)
182
+ const raw0 = (byte >> shift) & ((1 << bitDepth) - 1)
183
+ v = Math.round((raw0 * 255) / ((1 << bitDepth) - 1)) // 1/2/4 位要拉伸到 0..255
184
+ }
185
+ if (trnsGray !== null && bitDepth === 8 && v === (trnsGray & 0xff)) a = 0
186
+ r = v
187
+ g = v
188
+ b = v
189
+ if (colorType === 4) a = bitDepth === 16 ? samples[col * spp * 2 + 2] : samples[col * spp + 1]
190
+ } else {
191
+ // RGB / RGBA
192
+ if (bitDepth === 16) {
193
+ r = samples[col * pixelBytes]
194
+ g = samples[col * pixelBytes + 2]
195
+ b = samples[col * pixelBytes + 4]
196
+ if (colorType === 6) a = samples[col * pixelBytes + 6]
197
+ } else {
198
+ r = samples[col * spp]
199
+ g = samples[col * spp + 1]
200
+ b = samples[col * spp + 2]
201
+ if (colorType === 6) a = samples[col * spp + 3]
202
+ }
203
+ }
204
+ const o = (y * width + x) * 4
205
+ out[o] = r
206
+ out[o + 1] = g
207
+ out[o + 2] = b
208
+ out[o + 3] = a
209
+ }
210
+ }
211
+
212
+ /** 解一条扫描线的过滤并回写。bytesPerLine 是"含过滤器字节前"的字节数。 */
213
+ function readLine(samples, prev, cursor) {
214
+ const filterType = raw[cursor]
215
+ const line = raw.subarray(cursor + 1, cursor + 1 + samples.length)
216
+ line.copy(samples)
217
+ unfilterLine(samples, prev, filterType, pixelBytes)
218
+ return cursor + 1 + samples.length
219
+ }
220
+
221
+ if (interlace === 0) {
222
+ const bytesPerLine = Math.ceil((width * spp * bitDepth) / 8)
223
+ let prev = null
224
+ let cursor = 0
225
+ for (let y = 0; y < height; y += 1) {
226
+ const samples = Buffer.alloc(bytesPerLine)
227
+ cursor = readLine(samples, prev, cursor)
228
+ writeLine(samples, y, 0, 1, width)
229
+ prev = samples
230
+ }
231
+ } else if (interlace === 1) {
232
+ let cursor = 0
233
+ for (let pass = 0; pass < ADAM7.length; pass += 1) {
234
+ const [x0, y0, dx, dy] = ADAM7[pass]
235
+ const columns = Math.ceil((width - x0) / dx)
236
+ const rows = Math.ceil((height - y0) / dy)
237
+ if (columns <= 0 || rows <= 0) continue
238
+ const bytesPerLine = Math.ceil((columns * spp * bitDepth) / 8)
239
+ let prev = null
240
+ for (let row = 0; row < rows; row += 1) {
241
+ const samples = Buffer.alloc(bytesPerLine)
242
+ cursor = readLine(samples, prev, cursor)
243
+ writeLine(samples, y0 + row * dy, x0, dx, columns)
244
+ prev = samples
245
+ }
246
+ }
247
+ } else {
248
+ throw new Error('PNG interlace=' + interlace + ' 不支持')
249
+ }
250
+
251
+ return { width: width, height: height, data: out }
252
+ }
253
+
254
+ /** 是不是能被本模块解开的图片(按文件头判,不看后缀)。 */
255
+ export function looksLikeRaster(bytes) {
256
+ if (bytes.length < 12) return false
257
+ if (isPng(bytes)) return true
258
+ // JPEG(SOI)
259
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) return true
260
+ // GIF
261
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return true
262
+ // WebP(RIFF....WEBP)
263
+ if (
264
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
265
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
266
+ ) return true
267
+ return false
268
+ }
269
+
270
+ /**
271
+ * 非 PNG 的解码回退:**可选** sharp(宿主没装就返回 null,由调用方给一句人话报错)。
272
+ *
273
+ * 用动态 import 而不是顶层 import:没装 sharp 的部署不该连模块都加载不了。
274
+ * 路径依次试:包名、DSH profile 里的那份(本机是这么装的)。
275
+ */
276
+ async function decodeWithSharp(bytes) {
277
+ const candidates = [
278
+ 'sharp',
279
+ 'C:/Users/86476/AppData/Roaming/npm/node_modules/@deepseek-ai/dsh/node_modules/sharp',
280
+ ]
281
+ for (const spec of candidates) {
282
+ let sharp
283
+ try {
284
+ sharp = (await import(spec)).default
285
+ } catch (error) {
286
+ continue
287
+ }
288
+ try {
289
+ const got = await sharp(Buffer.from(bytes)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
290
+ return { width: got.info.width, height: got.info.height, data: got.data }
291
+ } catch (error) {
292
+ throw new Error('sharp 解不开这份图片:' + (error && error.message ? error.message : String(error)))
293
+ }
294
+ }
295
+ return null
296
+ }
297
+
298
+ /**
299
+ * 解一份图片文件成 RGBA8。
300
+ *
301
+ * PNG 走自带解码器(零依赖、结果确定);其余格式试 sharp,没有就给可执行的建议。
302
+ */
303
+ export async function decodeRaster(bytes) {
304
+ if (isPng(bytes)) return decodePng(bytes)
305
+ if (!looksLikeRaster(bytes)) {
306
+ throw new Error('这个文件不是能认的位图(PNG / JPEG / GIF / WebP)。要么换一张图,要么先把图转成 PNG。')
307
+ }
308
+ const viaSharp = await decodeWithSharp(bytes)
309
+ if (viaSharp === null) {
310
+ throw new Error('这种格式(非 PNG)需要 sharp 才能解;当前环境没装。把图转成 PNG(无损、体积也够小)再试。')
311
+ }
312
+ return viaSharp
313
+ }