dsh-drawai 0.1.1 → 0.2.1
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 +55 -28
- package/lib/client.js +10950 -8389
- package/lib/index.js +4107 -3776
- package/lib/mxfile.js +86 -15
- package/lib/raster.js +313 -0
- package/lib/style-kernel.js +209 -1
- package/lib/trace-image.js +904 -0
- package/package.json +3 -3
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|