dsh-plc-decode 0.1.5 → 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.
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hmismart-decode.mjs —— 西门子 WinCC flexible SMART 触摸屏工程(.hmismart)解码器
4
+ *
5
+ * 用法: node hmismart-decode.mjs <工程文件.hmismart> [输出目录]
6
+ * 输出(默认写到 工程文件同级 导出_hmismart_<时间戳>\):
7
+ * 00-格式说明.md 容器结构、魔数、页结构、编码约定
8
+ * 01-对象记录.tsv 偏移 / 类型GUID / 名称(全量,供查证)
9
+ * 02-对象分类.md 按类型GUID分组的对象清单(画面/变量/元件/图片…)
10
+ * 03-建表脚本.sql 文件内嵌的数据库 DDL(136 张表)
11
+ * 04-解压块.txt 全部 deflate 块解压内容(含属性包、连接配置)
12
+ * 05-系统消息_中文.txt 解压块里的多语言系统文本(中文)
13
+ * 06-可读文本.txt 文件正文里的 UTF-16 文本(去掉结构噪声)
14
+ * 07-统计.json
15
+ * 只读:不修改任何原始文件。
16
+ */
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import zlib from 'node:zlib'
20
+
21
+ const args = process.argv.slice(2)
22
+ if (!args[0]) { console.error('用法: node hmismart-decode.mjs <工程文件.hmismart> [输出目录]'); process.exit(1) }
23
+ const src = path.resolve(args[0])
24
+ if (!fs.existsSync(src)) { console.error('文件不存在: ' + src); process.exit(1) }
25
+ // 默认输出:当前工作目录下的「解码归档\<工程名>\HMI解码\」,不写工程目录、不写临时目录
26
+ const projectName = path.basename(src, path.extname(src))
27
+ const outDir = path.resolve(args[1] || path.join(process.cwd(), '解码归档', projectName, 'HMI解码'))
28
+ fs.mkdirSync(outDir, { recursive: true })
29
+
30
+ const buf = fs.readFileSync(src)
31
+ const stat = { file: src, size: buf.length, outputs: {} }
32
+ const w = (name, content) => { fs.writeFileSync(path.join(outDir, name), content); stat.outputs[name] = Buffer.byteLength(content) }
33
+
34
+ /* ---------- 1. 容器头 ---------- */
35
+ const magic = [...buf.subarray(0, 8)].map(b => b.toString(16).padStart(2, '0')).join(' ')
36
+ const header = {
37
+ magic,
38
+ size: buf.length,
39
+ pages4k: buf.length / 4096,
40
+ pageAligned: buf.length % 4096 === 0,
41
+ }
42
+
43
+ /* ---------- 2. DDL(数据库 schema) ---------- */
44
+ const asciiStrings = []
45
+ { let cur = '', start = 0
46
+ for (let i = 0; i < buf.length; i++) {
47
+ const c = buf[i]
48
+ if (c >= 32 && c < 127) { if (!cur) start = i; cur += String.fromCharCode(c) }
49
+ else { if (cur.length >= 6) asciiStrings.push([start, cur]); cur = '' }
50
+ } }
51
+ const ddl = asciiStrings.filter(([, s]) => /\b(CREATE TABLE|CREATE PROCEDURE|CREATE INDEX|ALTER TABLE|INSERT INTO|SELECT )/i.test(s))
52
+ const tables = new Set()
53
+ for (const [, s] of asciiStrings) for (const m of s.matchAll(/\b(Hmi[A-Za-z]{2,45}Table)\b/g)) tables.add(m[1])
54
+
55
+ /* ---------- 3. 解压全部 deflate 块 ---------- */
56
+ const blocks = []
57
+ for (let i = 0; i + 3 <= buf.length; i++) {
58
+ if (buf[i] === 0x1f && buf[i + 1] === 0x8b && buf[i + 2] === 0x08) {
59
+ try { blocks.push({ off: i, out: zlib.inflateRawSync(buf.subarray(i + 10)) }) } catch { }
60
+ i += 3
61
+ }
62
+ }
63
+ const uniqBlocks = []
64
+ { const seen = new Set()
65
+ for (const b of blocks) { const k = b.out.length + ':' + b.out.subarray(0, 64).toString('hex'); if (!seen.has(k)) { seen.add(k); uniqBlocks.push(b) } } }
66
+
67
+ /* ---------- 4. 对象记录:36 + UUIDv4 + 字段 + 名称 ---------- */
68
+ const isUuid4 = (o) => {
69
+ if (o + 16 > buf.length) return false
70
+ if ((buf[o + 7] >> 4) !== 4) return false
71
+ if ((buf[o + 8] & 0xc0) !== 0x80) return false
72
+ let z = 0; for (let i = 0; i < 16; i++) if (buf[o + i] === 0) z++
73
+ return z <= 3
74
+ }
75
+ const guidHex = (o) => [...buf.subarray(o, o + 16)].map(x => x.toString(16).padStart(2, '0')).join('')
76
+ const fmtGuid = h => `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
77
+ const isNameChar = c => (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x5f || c === 0x2d || c === 0x2e || c === 0x28 || c === 0x29 || c === 0xff08 || c === 0xff09
78
+ function readAt(s, limit = 60) { let j = s, str = '', ascii = 0
79
+ while (j + 1 < buf.length && str.length < limit) { const c = buf[j] | (buf[j + 1] << 8)
80
+ if (isNameChar(c) || c === 0x20) { str += String.fromCharCode(c); if (c < 0x80) ascii++; j += 2 } else break }
81
+ return { str, ascii } }
82
+ function readBest(from, back = 28) { let best = null
83
+ for (let s = from; s < from + back; s++) { const r = readAt(s)
84
+ if (r.str.length < 2 || r.str.length > 48) continue
85
+ const cjk = /[\u4e00-\u9fff]/.test(r.str)
86
+ const score = r.str.length + (cjk ? 6 : 0) + (r.ascii / r.str.length) * 4
87
+ if (!best || score > best.score) best = { off: s, ...r, score } }
88
+ return best }
89
+ const clean = s => s.replace(/^[^\u4e00-\u9fffA-Za-z0-9]+/, '').replace(/[\s]+$/, '').trim()
90
+
91
+ const rawRecs = []
92
+ for (let i = 0; i + 17 <= buf.length; i++) {
93
+ if (buf[i] !== 0x36 || !isUuid4(i + 1)) continue
94
+ const nm = readBest(i + 17)
95
+ if (!nm) continue
96
+ const name = clean(nm.str)
97
+ if (name.length < 2) continue
98
+ rawRecs.push({ off: i, g: guidHex(i + 1), name, cjk: /[\u4e00-\u9fff]/.test(name) })
99
+ }
100
+ const guidCount = new Map()
101
+ for (const r of rawRecs) guidCount.set(r.g, (guidCount.get(r.g) || 0) + 1)
102
+
103
+ // 去噪两道:① 真类型 GUID 会被反复引用;② 真对象名里的汉字会在多个名字里复现,
104
+ // 二进制误读出的"生僻字"通常只此一次(用字符 document-frequency 判定)
105
+ const charDF = new Map()
106
+ for (const r of rawRecs) for (const ch of new Set(r.name)) charDF.set(ch, (charDF.get(ch) || 0) + 1)
107
+ // 名称紧跟记录结构字节,尾部常粘 1~2 个"生僻字";用工程常用字表判边界,只截不在表内的尾字
108
+ const LEX = '报警速度时间延时开关开始结束检测故障复位启动停止画面切换变更当前自动手动调试设置密码用户产量清零位置位移编码器喷胶封盒吸盒吸托推料推托顶盒顶托挡料加料压托压盒压料整平整料输送带伺服文本变量计数寄存器标志锁机蜂鸣器原点缺料缺纸堵料剔废旋转测速测书测托测料测盒保护急停备用飞料放料托库盒子推杆隐藏工位比屏点动联机吸纸吹气气压卡盒有托回原日月年中主机少托少盒上料瓶子送料整机程序参数选择组站点数据语言字体作业主题色图形列表视图符号刻度棒图指针圆矩形线箭头图片库结口出入压舌联轴测瓶剔速差累计设定值单双头尾上下左右前后内外大小高低长短新旧的'
109
+ const trimNoise = (n) => {
110
+ let b = n.length
111
+ while (b > 1) {
112
+ const c = n[b - 1]
113
+ if (LEX.includes(c)) break
114
+ if (/[A-Za-z0-9_\.]/.test(c)) break
115
+ b--
116
+ }
117
+ return n.slice(0, b)
118
+ }
119
+ const cjkRatioOk = (n) => {
120
+ const cs = [...n].filter(c => c >= '\u4e00' && c <= '\u9fff')
121
+ if (!cs.length) return true
122
+ return cs.filter(c => (charDF.get(c) || 0) >= 5).length / cs.length >= 0.5
123
+ }
124
+ const hasNoiseChar = (n) => /[\u3400-\u4dbf\uf900-\ufaff\ufffd]/.test(n)
125
+ const recs = rawRecs
126
+ .filter(r => guidCount.get(r.g) >= 3 && !hasNoiseChar(r.name) && cjkRatioOk(r.name))
127
+ .map(r => ({ ...r, name: trimNoise(r.name) }))
128
+ .filter(r => r.name.length >= 2)
129
+
130
+ /* ---------- 5. 多语言系统消息(中文) ---------- */
131
+ const zhMsgs = new Set()
132
+ for (const b of uniqBlocks) {
133
+ const s = b.out.toString('utf8')
134
+ if (!s.includes('<text>')) continue
135
+ for (const m of s.matchAll(/zh-CN[^<]*<text>([\s\S]{1,200}?)<\/text>/g)) {
136
+ const t = m[1].replace(/<[^>]+>/g, '').trim()
137
+ if (t && t.length <= 120) zhMsgs.add(t)
138
+ }
139
+ }
140
+
141
+ /* ---------- 6. 正文 UTF-16 文本(排除 ASCII 区与压缩数据区) ---------- */
142
+ const mask = new Uint8Array(buf.length)
143
+ for (let i = 0; i < buf.length - 24; i += 8) { // ASCII 密集区
144
+ let a = 0; for (let j = 0; j < 24; j++) { const c = buf[i + j]; if (c >= 32 && c < 127) a++ }
145
+ if (a >= 20) for (let j = 0; j < 24; j++) mask[i + j] = 1
146
+ }
147
+ for (const b of blocks) for (let j = b.off; j < b.off + 24 && j < buf.length; j++) mask[j] = 1 // 压缩块头
148
+ const texts = new Set()
149
+ for (let i = 0; i + 3 < buf.length; i += 2) {
150
+ if (mask[i]) continue
151
+ const c = buf[i] | (buf[i + 1] << 8)
152
+ if (!((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x41 && c <= 0x7a))) continue
153
+ let j = i, s = ''
154
+ while (j + 1 < buf.length && !mask[j]) { const cc = buf[j] | (buf[j + 1] << 8)
155
+ if (isNameChar(cc) || cc === 0x20) { s += String.fromCharCode(cc); j += 2 } else break }
156
+ if (s.length >= 3 && /[\u4e00-\u9fff]/.test(s)) { const t = clean(s); if (t.length >= 2) texts.add(t) }
157
+ i = Math.max(i, j - 2)
158
+ }
159
+
160
+ /* ---------- 7. 分类 ---------- */
161
+ // 已确证的类型 GUID(靠"该类型下全部名称的语义"交叉验证得出,可继续扩充)
162
+ const KNOWN_TYPES = {
163
+ a8d0ec5ba448dd4e8cfd1f93bab80357: '画面',
164
+ '98441ece8aaa384190250908339aa88e': '变量(HMI 变量表)',
165
+ '2eee2253373c9e4fb5b9bd3a205f6713': '对象类型目录(元件类型名)',
166
+ '87c833306870a9498ae24da1919746b3': '画面元件(IO域/文本域/开关)',
167
+ '50ed3daea5a013448ae19ed8f4043d9c': '画面元件(按钮/文本域)',
168
+ e95e1fa2f2f8b946b47532cc5399bd9f: '画面元件(按钮/线)',
169
+ '7cc6b8efde529f4aab60be9937564c5f': '画面元件(矩形/按钮/开关/图形)',
170
+ a75c8f057bd20e45b5ca070891443d1d: '画面元件(IO域/开关/文本域)',
171
+ d6ae66afa96fb3408df18d44f2585873: '画面元件(IO域/开关/文本域/报警视图)',
172
+ a1b2d38181484d489401867b9fdcc709: '模板元件(模板按钮/IO域/开关)',
173
+ d65b6511ea528442af7579c84c001e54: '图片资源',
174
+ '0c0a9fede4f16d438fed097fa8051506': '通讯连接',
175
+ '35b3711327ef66408b82c3097100029e': 'Smart 主题/样式(软件自带)',
176
+ '11fea9ca9da43d4289fe389107a3e925': '字体列表',
177
+ '49a4d64cb82e814292102c7fd2007115': '系统函数',
178
+ d0aeebe5b8663d478fb6b5f4ac6c9d96: '项目结构节点',
179
+ '3848614b96efc141bcd4124814bbf961': '项目结构节点',
180
+ b0e0cad9a3bb074da9cf296d0573e254: '项目树节点',
181
+ cc1746b51be8f74b8cef934dd946b81b: '系统变量(画面号/时间等)',
182
+ e2a88f13a660734fbe15ec7df20c04a8: '用户与权限',
183
+ ad962e667c6b5c4887ae1d834a4205b8: '报警确认组',
184
+ d912bd6b8243fd4384aa4d3bfa5650ef: '用户/授权',
185
+ fa8ef5c6e7da2d4b984bb1e109cf948b: '采集周期',
186
+ '3957818870145243b9e0a928e3f16129': '图形列表',
187
+ c67c51d88bbb4c49840423ac3926928e: '报警类别/事件',
188
+ '00a28e5290ccc64f937a905f6222ffea': '数据类型与消息文件夹',
189
+ '02c4e0f59ad3c34d9d6baaf4fb8a6bf6': '数据类型与文本文件夹',
190
+ '7a065037c383944f9bbf977931038c84': '用户/授权管理',
191
+ '912c1ca9472b964e891aa54b3f27a71f': '图库条目',
192
+ '658b5e3163262b47bc8f4c14c316f7eb': '报警消息文件夹',
193
+ }
194
+ const PATTERNS = [
195
+ ['画面', /画面/],
196
+ ['画面元件', /^(按钮|文本域|IO 域|开关|矩形|线|圆|图形|报警视图|日期时间域|符号|笑脸|棒图|指针|刻度)/],
197
+ ['模板元件', /^模板/],
198
+ ['变量', /(报警|速度|计数|清零|加料|压盒|压托|卡盒|用户名|用户密码|当前[日年月]|^[QIMVDB]\d)/],
199
+ ['图片/图形资源', /(QQ截图|微信图片|GRAPHIC|箭头|图片|起始位置)/],
200
+ ['通讯/连接', /(连接|Communication|S7ONLINE|TCPIP|ETHERNET)/],
201
+ ['报警/用户/配方', /(确认组|报警组|报警类别|配方|激活码|权限|管理员|操作员)/],
202
+ ['系统函数', /^(ActivateScreen|GetPassword|GetUserName|InvertBit|Logoff|Logon|SetBit|SetValue|IncreaseValue|StartDataTransfer|ChangeConnection|SetLanguage|AddressCulture)/],
203
+ ['数据类型', /(Internal$|SIMATIC S7)/],
204
+ ['项目结构/系统对象', /(Folder|Settings|Reports|Messages|Library|Modules|Functions|Global|Prototype|Documentation|CrossReference|Devices|Images|Lists|Containers)/],
205
+ ['样式/主题', /^Smart-/],
206
+ ['时间/周期', /^\d+ ?(ms|s|min|h)$/],
207
+ ]
208
+ function classify(g) {
209
+ if (KNOWN_TYPES[g]) return KNOWN_TYPES[g]
210
+ const names = [...new Set(recs.filter(r => r.g === g).map(r => r.name))]
211
+ if (!names.length) return '其他'
212
+ // 疑似噪声区:中文名极少且名字短碎,判为二进制误命中
213
+ const cjkNames = names.filter(n => /[\u4e00-\u9fff]/.test(n)).length
214
+ if (cjkNames / names.length < 0.15 && names.length >= 8) return '疑似二进制区(非对象)'
215
+ let best = null
216
+ for (const [label, re] of PATTERNS) {
217
+ const hit = names.filter(n => re.test(n)).length / names.length
218
+ if (hit >= 0.5 && (!best || hit > best.hit)) best = { label, hit }
219
+ }
220
+ return best ? best.label : '其他/待定'
221
+ }
222
+
223
+ /* ---------- 7b. 结构化表(压缩块里的 XML 表;2026-11-06 新增) ---------- */
224
+ // 新版 .hmismart 的压缩块里是 XML 表(HmiScreenTable / HmiScreenItemTable / HmiTextTable / HmiDeviceTable)。
225
+ // 解出来的表比整份 dump 小一个数量级,按问题查表即可,不必读 dump。
226
+ function buildStructTables(blockTexts) {
227
+ const grab = (body, tag) => { const m = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(body); return m ? m[1] : '' }
228
+ // 只在单个压缩块内匹配完整记录:跨块拼接会把「开始标签在前一块、结束标签在后一块」的
229
+ // 残片吞进来(body 里混进别的表,取到错名字)。块内切不完整的记录直接丢弃。
230
+ const rows = (tag) => {
231
+ const out = []
232
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g')
233
+ for (const t of blockTexts) for (const m of t.matchAll(re)) out.push(m[1])
234
+ return out
235
+ }
236
+ const screens = rows('HmiScreenTable').map(b => ({
237
+ id: grab(b, 'ObjId'), name: grab(b, 'ObjName'), number: grab(b, 'Number'),
238
+ deleted: grab(b, 'Deleted'), device: grab(b, 'DeviceNumber'), mtime: grab(b, 'LastModified'),
239
+ })).filter(r => r.name)
240
+ const byId = new Map(screens.map(r => [r.id, r]))
241
+ const items = rows('HmiScreenItemTable').map(b => ({
242
+ id: grab(b, 'ObjId'), name: grab(b, 'ObjName'), type: grab(b, 'HmiSubType'),
243
+ screenId: grab(b, 'ParentId'), x: grab(b, 'LocationX'), y: grab(b, 'LocationY'),
244
+ w: grab(b, 'SizeX'), h: grab(b, 'SizeY'), mtime: grab(b, 'LastModified'),
245
+ })).filter(r => r.name)
246
+ const itemById = new Map(items.map(r => [r.id, r]))
247
+ const texts = rows('HmiTextTable').map(b => ({
248
+ ownerId: grab(b, 'ContainerId'), type: grab(b, 'TextType'),
249
+ sys: grab(b, 'IsSystemMessageText'), text: grab(b, 'Language0'), mtime: grab(b, 'LastModified'),
250
+ })).filter(r => r.text)
251
+ const devices = rows('HmiDeviceTable').map(b => ({
252
+ name: grab(b, 'ObjName'), model: grab(b, 'DeviceType'), startId: grab(b, 'StartScreenId'),
253
+ w: grab(b, 'ScreenSizeWidth'), h: grab(b, 'ScreenSizeHeight'), mtime: grab(b, 'LastModified'),
254
+ })).filter(r => r.name)
255
+ // 类型密码本:TypeCode ↔ 类型全名(.data 里判断对象/引用类型用)
256
+ const types = rows('HmiTypeTable').map(b => ({
257
+ code: grab(b, 'TypeCode'), name: grab(b, 'TypeName'), id: grab(b, 'TypeId'),
258
+ })).filter(r => r.name && r.code)
259
+ // 同一对象在多个压缩块里出现(工程内多版本快照),按主键取 LastModified 最新的一条
260
+ const dedupe = (arr, keyFn) => {
261
+ const m = new Map()
262
+ for (const r of arr) {
263
+ const k = keyFn(r), cur = m.get(k)
264
+ if (!cur || String(r.mtime || '') >= String(cur.mtime || '')) m.set(k, r)
265
+ }
266
+ return [...m.values()]
267
+ }
268
+ return {
269
+ screens: dedupe(screens, r => r.id || r.name),
270
+ items: dedupe(items, r => r.id || r.name),
271
+ texts: dedupe(texts, r => `${r.ownerId}|${r.type}|${r.text}`),
272
+ devices: dedupe(devices, r => `${r.name}|${r.model}`),
273
+ types: dedupe(types, r => r.code),
274
+ byId, itemById,
275
+ }
276
+ }
277
+ const struct = buildStructTables(blocks.map(b => b.out.toString('utf8')))
278
+
279
+ /* ---------- 7c. 旁系 .data(ES2RT 序列化):真实变量/控件名 ---------- */
280
+ // .hmismart 里只有模板骨架;真实画面/控件/变量在 <工程名>_{{GUID}}.data:
281
+ // 'ES2RT' 头 + gzip → 解出 18MB 的 .NET 序列化对象流,UTF-8 字符串可读。
282
+ // 目前能可靠抽出三族名字;控件→变量的绑定引用未解出(标在报告里)。
283
+ function extractDataNames(file) {
284
+ try {
285
+ const d = fs.readFileSync(file)
286
+ const gi = d.indexOf(Buffer.from([0x1f, 0x8b, 0x08]))
287
+ if (gi < 0) return null
288
+ const raw = zlib.inflateRawSync(d.subarray(gi + 10))
289
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(raw)
290
+ const BAD = /hmiType|objName|esHandle|lang2Text|missingText|TextList|messageParameters|isProjectedText|System\.|Siemens\.|ES2RT|k__BackingField|Dependencies|CustomMaxTextLength|theCauseHandle|idParent/
291
+ const isName = (s) => {
292
+ if (s.length < 2 || s.length > 24) return false
293
+ if (BAD.test(s)) return false
294
+ if (/[\u4e00-\u9fff]/.test(s)) return true
295
+ if (/^[QIMVWDB]\d{1,5}(\.\d)?$/i.test(s)) return true
296
+ return false
297
+ }
298
+ const pick = (seg) => {
299
+ const runs = [...seg.matchAll(/[\u4e00-\u9fffA-Za-z0-9 _.\-]{2,24}/g)].map(m => m[0].trim()).filter(isName)
300
+ return runs.length ? runs[runs.length - 1] : ''
301
+ }
302
+ const collect = (tn) => {
303
+ const names = []
304
+ let p = -1
305
+ while ((p = text.indexOf(tn, p + 1)) >= 0) {
306
+ const n = pick(text.slice(Math.max(0, p - 600), p))
307
+ if (n) names.push(n)
308
+ }
309
+ return [...new Set(names)]
310
+ }
311
+ // 控件→变量:按 ES2RT 段切分(相邻真头之间为一段),控件段内出现变量名(词边界)即绑定候选
312
+ const heads = []
313
+ for (let i = 0; i + 12 < raw.length; i++) {
314
+ if (raw[i] === 0x45 && raw[i + 1] === 0x53 && raw[i + 2] === 0x32 && raw[i + 3] === 0x52 && raw[i + 4] === 0x54 && raw[i + 5] === 0x01) heads.push(i)
315
+ }
316
+ const segText = (k) => new TextDecoder('utf-8', { fatal: false }).decode(raw.subarray(heads[k], k + 1 < heads.length ? heads[k + 1] : raw.length))
317
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
318
+ const hasWord = (t, w) => new RegExp(`(^|[^\\u4e00-\\u9fffA-Za-z0-9_])${esc(w)}([^\\u4e00-\\u9fffA-Za-z0-9_]|$)`).test(t)
319
+ const tagList = [...new Set(collect('Siemens.Simatic.Hmi.Utah.Tag.HmiTag'))]
320
+ const isUuid4 = (b, o) => o + 16 <= b.length && (b[o + 7] >> 4) === 4 && (b[o + 8] & 0xc0) === 0x80
321
+ const uuidsOf = (b) => { const o = []; for (let i = 0; i + 16 <= b.length; i++) if (isUuid4(b, i)) o.push(b.subarray(i, i + 16).toString('hex')); return o }
322
+ const segBuf = (k) => raw.subarray(heads[k], k + 1 < heads.length ? heads[k + 1] : raw.length)
323
+ // 变量对象 id:每段结构固定,对象 id 在段内偏移 0x19(16 字节)
324
+ const segId = (b) => b.length > 0x29 ? b.subarray(0x19, 0x29).toString('hex') : ''
325
+ const tagByUuid = new Map()
326
+ for (let k = 0; k < heads.length; k++) {
327
+ const t = segText(k)
328
+ if (!t.includes('Siemens.Simatic.Hmi.Utah.Tag.HmiTag')) continue
329
+ const n = pick(t), id = segId(segBuf(k))
330
+ if (n && id && !tagByUuid.has(id)) tagByUuid.set(id, n)
331
+ }
332
+ const bindings = []
333
+ const seen = new Set()
334
+ const pushB = (item, tag, by) => { const k = item + '\u0001' + tag; if (!seen.has(k)) { seen.add(k); bindings.push({ item, tag, by }) } }
335
+ for (let k = 0; k < heads.length; k++) {
336
+ const t = segText(k)
337
+ if (!t.includes('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreenItem')) continue
338
+ const name = pick(t)
339
+ if (!name) continue
340
+ for (const w of tagList) if (w !== name && hasWord(t, w)) pushB(name, w, '同段共现')
341
+ const b2 = segBuf(k)
342
+ for (const u of new Set(uuidsOf(b2))) {
343
+ const w = tagByUuid.get(u)
344
+ if (w && w !== name) pushB(name, w, 'UUID 引用')
345
+ }
346
+ }
347
+ return {
348
+ file: path.basename(file),
349
+ bytes: d.length,
350
+ inflated: raw.length,
351
+ segments: heads.length,
352
+ screens: collect('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreen'),
353
+ items: collect('Siemens.Simatic.Hmi.Utah.GraphX.HmiScreenItem'),
354
+ tags: collect('Siemens.Simatic.Hmi.Utah.Tag.HmiTag'),
355
+ bindings,
356
+ }
357
+ } catch { return null }
358
+ }
359
+ let dataNames = null
360
+ try {
361
+ const dir = path.dirname(src)
362
+ const cand = fs.readdirSync(dir).filter(f => f.toLowerCase().endsWith('.data'))
363
+ if (cand.length) {
364
+ // 取最大的那个:同目录常有两个 .data,小的那个只存键值不入对象
365
+ let best = cand[0], bestSize = -1
366
+ for (const f of cand) {
367
+ try { const sz = fs.statSync(path.join(dir, f)).size; if (sz > bestSize) { best = f; bestSize = sz } } catch { }
368
+ }
369
+ dataNames = extractDataNames(path.join(dir, best))
370
+ }
371
+ } catch { }
372
+
373
+ /* ---------- 输出 ---------- */
374
+ const report = []
375
+ report.push(`# .hmismart 解码报告`, '', `- 源文件:${src}`, `- 文件大小:${buf.length} 字节(${(buf.length / 1048576).toFixed(2)} MB)`, `- 文件头魔数:\`${magic}\``, `- 页结构:${header.pageAligned ? `页对齐,4096 × ${header.pages4k} 页` : '非 4096 对齐'}`, `- 压缩块:${blocks.length} 个(去重 ${uniqBlocks.length}),解压总量 ${(blocks.reduce((a, b) => a + b.out.length, 0) / 1048576).toFixed(2)} MB`, `- 内嵌数据表:${tables.size} 张`, `- 对象记录:${rawRecs.length} 条(有效类型 ${guidCount.size} 种,过滤后 ${recs.length} 条)`, `- 中文系统消息:${zhMsgs.size} 条`, `- 正文可读文本:${texts.size} 条`, '')
376
+ report.push(`- 结构化表:画面 ${struct.screens.length} / 元件 ${struct.items.length} / 文本 ${struct.texts.length} / 设备 ${struct.devices.length}(来自 .hmismart 内嵌 XML 表;0 表示本格式无 XML 表,见 01/01b/02。真实画面/控件与绑定变量在旁系 {{GUID}}.data 与 .srt,不在这张表里)`)
377
+ if (dataNames) report.push(`- 旁系 .data:${dataNames.file}(${(dataNames.bytes / 1048576).toFixed(1)} MB → 解压 ${(dataNames.inflated / 1048576).toFixed(1)} MB / ${dataNames.segments} 段)|变量名 ${dataNames.tags.length} / 控件名 ${dataNames.items.length} / 画面候选 ${dataNames.screens.length} / 绑定候选 ${dataNames.bindings.length}(见 20/21/22/23)`)
378
+ else report.push('- 旁系 .data:未找到(同目录无 {{GUID}}.data)')
379
+ w('00-格式说明.md', report.join('\n'))
380
+
381
+ w('01-对象记录.tsv', '偏移\t类型GUID\t名称\n' + rawRecs.map(r => `0x${r.off.toString(16)}\t${fmtGuid(r.g)}\t${r.name}`).join('\n'))
382
+ w('01b-对象记录-清洗.tsv', '偏移\t类型GUID\t名称\n' + recs.map(r => `0x${r.off.toString(16)}\t${fmtGuid(r.g)}\t${r.name}`).join('\n'))
383
+
384
+ const groups = [...guidCount.entries()].filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1])
385
+ const cat = new Map()
386
+ for (const [g] of groups) { const k = classify(g); if (!cat.has(k)) cat.set(k, []); cat.get(k).push(g) }
387
+ const catLines = ['# 对象分类清单', '', '每行 = 类型GUID|记录数|该类型下的全部唯一名称(去重)。', '']
388
+ for (const [k, gs] of [...cat.entries()].sort((a, b) => b[1].length - a[1].length)) {
389
+ const total = gs.reduce((a, g) => a + guidCount.get(g), 0)
390
+ catLines.push(`## ${k}(${gs.length} 类 / ${total} 条)`, '')
391
+ for (const g of gs) {
392
+ const names = [...new Set(recs.filter(r => r.g === g).map(r => r.name))]
393
+ catLines.push(`- \`${fmtGuid(g)}\` ×${guidCount.get(g)}|唯一 ${names.length}`)
394
+ catLines.push(' ' + names.join('、'))
395
+ }
396
+ catLines.push('')
397
+ }
398
+ w('02-对象分类.md', catLines.join('\n'))
399
+
400
+ w('03-建表脚本.sql', ddl.map(([o, s]) => `-- 0x${o.toString(16)}\n${s}`).join('\n\n'))
401
+
402
+ // 解压块分两堆:工程数据(属性包/对象数据)与软件自带的多语言系统文本
403
+ const isMultiLang = (s) => /<text>/.test(s) && /hu-HU|cs-CZ|ja-JP|zh-TW|de-DE|en-US|fr-FR|es-ES|it-IT|pt-PT|ru-RU|ko-KR/.test(s)
404
+ const propBlocks = [], langBlocks = []
405
+ for (const b of uniqBlocks) (isMultiLang(b.out.toString('utf8')) ? langBlocks : propBlocks).push(b)
406
+ const dumpBlock = (b, k) => `\n===BLK#${k} @0x${b.off.toString(16)} len=${b.out.length}===\n` + b.out.toString('utf8').replace(/[^\x09\x0a\x20-\x7e\u4e00-\u9fff]/g, '·')
407
+ w('04-解压块-工程数据.txt', propBlocks.map(dumpBlock).join('\n'))
408
+ w('05-解压块-多语言文本.txt', langBlocks.map(dumpBlock).join('\n'))
409
+
410
+ fs.writeFileSync(path.join(outDir, '06-系统消息_中文.txt'), [...zhMsgs].sort().join('\n'))
411
+ stat.outputs['06-系统消息_中文.txt'] = fs.statSync(path.join(outDir, '06-系统消息_中文.txt')).size
412
+
413
+ fs.writeFileSync(path.join(outDir, '07-可读文本.txt'), [...texts].sort().join('\n'))
414
+ stat.outputs['07-可读文本.txt'] = fs.statSync(path.join(outDir, '07-可读文本.txt')).size
415
+
416
+ /* 结构化表落盘:一份问题一张表,AI 按问题只读对应那份 */
417
+ const tsv = (head, lines) => head + '\n' + lines.join('\n') + (lines.length ? '\n' : '')
418
+ const ownerOf = id => (struct.itemById.get(id) || struct.byId.get(id) || { name: String(id).slice(0, 8) }).name
419
+ if (struct.screens.length) {
420
+ w('10-画面.tsv', tsv('画面名\t编号\t已删除\t设备号\tObjId', struct.screens.map(r => `${r.name}\t${r.number}\t${r.deleted}\t${r.device}\t${r.id}`)))
421
+ }
422
+ if (struct.items.length) {
423
+ w('11-元件.tsv', tsv('画面\t元件名\t类型\tX\tY\t宽\t高\tObjId', struct.items.map(r => `${(struct.byId.get(r.screenId) || {}).name || ''}\t${r.name}\t${r.type}\t${r.x}\t${r.y}\t${r.w}\t${r.h}\t${r.id}`)))
424
+ }
425
+ if (struct.texts.length) {
426
+ w('12-文本.tsv', tsv('所属\t文本类型\t系统消息\t文本', struct.texts.map(r => `${ownerOf(r.ownerId)}\t${r.type}\t${r.sys}\t${r.text.replace(/\t/g, ' ')}`)))
427
+ }
428
+ if (struct.devices.length) {
429
+ w('13-设备.tsv', tsv('设备名\t型号\t起始画面ID\t起始画面\t分辨率', struct.devices.map(r => {
430
+ const s = struct.byId.get(r.startId)
431
+ return `${r.name}\t${r.model}\t${r.startId}\t${s ? s.name : '(不在画面表)'}\t${r.w}×${r.h}`
432
+ })))
433
+ }
434
+ if (struct.types.length) {
435
+ w('24-类型表.tsv', tsv('TypeCode\t类型全名', struct.types.map(r => `${r.code}\t${r.name}`)))
436
+ }
437
+
438
+ /* 旁系 .data 名字清单:真实变量/控件在这里,.hmismart 里只有模板骨架 */
439
+ if (dataNames) {
440
+ w('20-变量.data.tsv', '变量名\n' + dataNames.tags.join('\n') + (dataNames.tags.length ? '\n' : ''))
441
+ w('21-控件.data.tsv', '控件名\n' + dataNames.items.join('\n') + (dataNames.items.length ? '\n' : ''))
442
+ w('22-画面候选.data.txt', '# 画面候选(ES2RT 邻近名,可能混入控件名;未与画面表对齐)\n' + dataNames.screens.join('\n') + '\n')
443
+ if (dataNames.bindings.length) {
444
+ w('23-控件绑定.data.tsv', '控件\t变量\t依据\n' + dataNames.bindings.map(b => `${b.item}\t${b.tag}\t${b.by}`).join('\n') + '\n')
445
+ }
446
+ }
447
+
448
+ w('08-统计.json', JSON.stringify({ ...stat, tables: [...tables].sort(), blocks: blocks.length, uniqBlocks: uniqBlocks.length, propBlocks: propBlocks.length, langBlocks: langBlocks.length, blockBytes: blocks.reduce((a, b) => a + b.out.length, 0), recs: rawRecs.length, recsKept: recs.length, guidTypes: guidCount.size, zhMsgs: zhMsgs.size, texts: texts.size, structRows: { screens: struct.screens.length, items: struct.items.length, texts: struct.texts.length, devices: struct.devices.length }, dataNames: dataNames ? { file: dataNames.file, bytes: dataNames.bytes, inflated: dataNames.inflated, segments: dataNames.segments, tags: dataNames.tags.length, items: dataNames.items.length, screens: dataNames.screens.length, bindings: dataNames.bindings.length } : null }, null, 2))
449
+
450
+ console.log(`[完成] 输出目录:${outDir}`)
451
+ for (const [k, v] of Object.entries(stat.outputs)) console.log(` ${k} ${(v / 1024).toFixed(1)} KB`)
452
+ console.log(` 内嵌表 ${tables.size} 张|对象记录 ${rawRecs.length}→${recs.length} 条|类型 ${guidCount.size} 种|压缩块 ${blocks.length}(唯一 ${uniqBlocks.length}:工程数据 ${propBlocks.length} / 多语言 ${langBlocks.length})|中文系统消息 ${zhMsgs.size}|可读文本 ${texts.size}`)
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hmismart-diff.mjs —— 对比新旧两份 WinCC flexible SMART 触摸屏工程(.hmismart)
4
+ *
5
+ * 用法: node hmismart-diff.mjs <旧工程.hmismart> <新工程.hmismart> [输出目录]
6
+ * 输出: <输出目录>/HMI差异报告.md(默认写到 旧工程同目录\HMI差异_<时间戳>\)
7
+ *
8
+ * 比什么:
9
+ * 1. 容器的起始画面配置(StartScreenId / StartScreenType)—— 重启后显示哪个画面就看它
10
+ * 2. 画面表记录(ObjId / 名称 / 删除标志)
11
+ * 3. 明文区画面名清单
12
+ * 4. 画面对象 GUID(来自 ES2RT .data,逐名对照)
13
+ * 5. 配套文件(.srt 项目文本 / .data 组态数据)的大小与文本差异
14
+ * 只读,不改动任何工程文件。
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import zlib from 'node:zlib'
19
+
20
+ /* ───────── 基础 ───────── */
21
+ function readGuarded(file) {
22
+ try { return fs.readFileSync(file) }
23
+ catch (e) {
24
+ if (e.code === 'EBUSY' || /being used by another process/.test(e.message)) {
25
+ throw new Error(`文件被独占锁定(很可能 WinCC flexible SMART 正打开着它):${file}\n`
26
+ + '处理:在软件里「另存为」一份到别的目录,或关闭软件后重试。')
27
+ }
28
+ throw e
29
+ }
30
+ }
31
+ function inflateBlocks(buf) {
32
+ const out = []
33
+ for (let i = 0; i + 3 <= buf.length; i++) {
34
+ if (buf[i] === 0x1f && buf[i + 1] === 0x8b && buf[i + 2] === 0x08) {
35
+ try { out.push(zlib.inflateRawSync(buf.subarray(i + 10))) } catch { }
36
+ i += 3
37
+ }
38
+ }
39
+ return out
40
+ }
41
+ const norm = h => `${h.slice(6, 8)}${h.slice(4, 6)}${h.slice(2, 4)}${h.slice(0, 2)}-${h.slice(10, 12)}${h.slice(8, 10)}-${h.slice(14, 16)}${h.slice(12, 14)}-${h.slice(16, 20)}-${h.slice(20)}`
42
+
43
+ /* ───────── 提取 ───────── */
44
+ function probe(file) {
45
+ const buf = readGuarded(file)
46
+ const blocks = inflateBlocks(buf)
47
+ const text = Buffer.concat(blocks).toString('utf8')
48
+
49
+ const dev = []
50
+ for (const m of text.matchAll(/<DeviceType>([^<]*)<\/DeviceType>[\s\S]{0,80}?<StartScreenType>([^<]*)<\/StartScreenType>[^<]*<StartScreenId>([^<]*)<\/StartScreenId>/g)) {
51
+ dev.push({ deviceType: m[1], startScreenType: m[2], startScreenId: m[3] })
52
+ }
53
+ const screens = new Map()
54
+ for (const m of text.matchAll(/<HmiScreenTable>([\s\S]*?)<\/HmiScreenTable>/g)) {
55
+ const b = m[1]
56
+ const g = n => { const r = b.match(new RegExp(`<${n}>([^<]*)</${n}>`)); return r ? r[1] : '' }
57
+ const id = g('ObjId'); if (id) screens.set(id, { id, name: g('ObjName'), number: g('Number'), deleted: g('Deleted') })
58
+ }
59
+ const names = new Set()
60
+ // 画面对象记录:36 + 类型GUID(16B) + 字段 + 名称(UTF-16LE);画面类型 GUID 固定
61
+ const SCREEN_TYPE = 'a8d0ec5ba448dd4e8cfd1f93bab80357'
62
+ const isUuid4 = o => o + 16 <= buf.length && (buf[o + 7] >> 4) === 4 && (buf[o + 8] & 0xc0) === 0x80
63
+ for (let i = 0; i + 17 < buf.length; i++) {
64
+ if (buf[i] !== 0x36 || !isUuid4(i + 1)) continue
65
+ let g = ''
66
+ for (let k = i + 1; k < i + 17; k++) g += buf[k].toString(16).padStart(2, '0')
67
+ if (g !== SCREEN_TYPE) continue
68
+ for (let s = i + 17; s < i + 45; s++) {
69
+ const c = buf[s] | (buf[s + 1] << 8)
70
+ if (!((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x41 && c <= 0x5a))) continue
71
+ let j = s, str = ''
72
+ while (j + 1 < buf.length && str.length < 40) {
73
+ const cc = buf[j] | (buf[j + 1] << 8)
74
+ if ((cc >= 0x4e00 && cc <= 0x9fff) || (cc >= 0x30 && cc <= 0x39) || (cc >= 0x41 && cc <= 0x5a) || (cc >= 0x61 && cc <= 0x7a) || cc === 0x5f || cc === 0x2d || cc === 0x2e || cc === 0x20) { str += String.fromCharCode(cc); j += 2 } else break
75
+ }
76
+ if (str.length >= 2) names.add(str)
77
+ break
78
+ }
79
+ }
80
+ // 配套文件
81
+ const dir = path.dirname(file), base = path.basename(file, path.extname(file))
82
+ const peers = fs.readdirSync(dir).filter(f => f.startsWith(base) && f !== path.basename(file) && fs.statSync(path.join(dir, f)).isFile())
83
+ return { file, size: buf.length, blocks: blocks.length, dev, screens, names, peers, dir, base }
84
+ }
85
+
86
+ function peerInfo(dir, base, name) {
87
+ const p = path.join(dir, name)
88
+ if (!fs.existsSync(p)) return null
89
+ const st = fs.statSync(p)
90
+ let texts = null
91
+ if (/\.srt$|\.transfer$|\.modified$/.test(name)) {
92
+ try {
93
+ const b = fs.readFileSync(p); const set = new Set()
94
+ for (let i = 0; i + 1 < b.length; i += 2) {
95
+ const c = b[i] | (b[i + 1] << 8)
96
+ if (!((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a))) continue
97
+ let j = i, s = ''
98
+ while (j + 1 < b.length && s.length < 60) {
99
+ const cc = b[j] | (b[j + 1] << 8)
100
+ if ((cc >= 0x4e00 && cc <= 0x9fff) || (cc >= 0x30 && cc <= 0x39) || (cc >= 0x41 && cc <= 0x5a) || (cc >= 0x61 && cc <= 0x7a) || cc === 0x5f || cc === 0x2d || cc === 0x2e || cc === 0x20) { s += String.fromCharCode(cc); j += 2 } else break
101
+ }
102
+ if (s.length >= 2) set.add(s)
103
+ i = Math.max(i, j - 2)
104
+ }
105
+ texts = set
106
+ } catch { }
107
+ }
108
+ return { size: st.size, texts }
109
+ }
110
+
111
+ /* ───────── 主流程 ───────── */
112
+ const [oldFile, newFile, outArg] = process.argv.slice(2)
113
+ if (!oldFile || !newFile) { console.error('用法: node hmismart-diff.mjs <旧工程.hmismart> <新工程.hmismart> [输出目录]'); process.exit(1) }
114
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
115
+ // 默认落工作区统一归档目录,不写工程目录(用户约定)
116
+ const outDir = path.resolve(outArg || path.join(process.cwd(), '解码归档', path.basename(path.resolve(oldFile), path.extname(oldFile)), 'HMI差异_' + stamp))
117
+ fs.mkdirSync(outDir, { recursive: true })
118
+
119
+ const A = probe(path.resolve(oldFile))
120
+ const B = probe(path.resolve(newFile))
121
+ const L = []
122
+ const P = (...s) => L.push(...s)
123
+
124
+ P('# HMI 工程差异报告', '')
125
+ P(`- 旧版:\`${A.file}\` ${A.size.toLocaleString()} 字节`)
126
+ P(`- 新版:\`${B.file}\` ${B.size.toLocaleString()} 字节`)
127
+ P(`- 容器大小:${A.size === B.size ? '**完全相同**(原地改:改的是定长字段)' : `差 ${B.size - A.size} 字节`}`)
128
+ P('')
129
+
130
+ /* 1. 起始画面 */
131
+ P('## 1. 起始画面配置(决定重启后显示哪个画面)', '')
132
+ const devA = A.dev[0] || {}, devB = B.dev[0] || {}
133
+ P('| 项 | 旧版 | 新版 | 判定 |')
134
+ P('|---|---|---|---|')
135
+ for (const k of ['startScreenType', 'startScreenId']) {
136
+ const a = devA[k] ?? '(无)', b = devB[k] ?? '(无)'
137
+ P(`| ${k} | \`${a}\` | \`${b}\` | ${a === b ? '相同' : '**不同** ← 改动点'} |`)
138
+ }
139
+ P('')
140
+ const screenOf = (S, id) => S.screens.get(id)
141
+ const sa = screenOf(A, devA.startScreenId), sb = screenOf(B, devB.startScreenId)
142
+ P(`- 旧版起始画面指向:${sa ? `\`${sa.name}\`${sa.deleted === 'true' ? '(**已删除**)' : ''}` : '(不在画面表内)'}`)
143
+ P(`- 新版起始画面指向:${sb ? `\`${sb.name}\`${sb.deleted === 'true' ? '(**已删除**)' : ''}` : '(不在画面表内)'}`)
144
+ if (sa && sa.deleted === 'true' && sb && sb.deleted !== 'true') P('\n> **根因**:旧版起始画面指向一个「已删除」的画面,面板启动找不到它就会退到其它画面(表现即「重启后跳到别的画面」);新版已改为有效画面。')
145
+ else if (!sa || !sb) P('\n> 提示:起始画面 ID 未出现在画面表里,本报告无法判定它指向哪个画面。')
146
+ P('')
147
+
148
+ /* 2. 画面表 */
149
+ P('## 2. 画面表记录(HmiScreenTable)', '')
150
+ P('| 名称 | 旧版 | 新版 |')
151
+ P('|---|---|---|')
152
+ const ids = new Set([...A.screens.keys(), ...B.screens.keys()])
153
+ for (const id of ids) {
154
+ const x = A.screens.get(id), y = B.screens.get(id)
155
+ P(`| ${(x || y).name} | ${x ? `删除=${x.deleted} 编号=${x.number}` : '—'} | ${y ? `删除=${y.deleted} 编号=${y.number}` : '—'} |`)
156
+ }
157
+ P('')
158
+
159
+ /* 3. 画面名清单 */
160
+ P('## 3. 明文区画面名清单', '')
161
+ const onlyA = [...A.names].filter(n => !B.names.has(n))
162
+ const onlyB = [...B.names].filter(n => !A.names.has(n))
163
+ P(`- 旧版 ${A.names.size} 个|新版 ${B.names.size} 个`)
164
+ if (onlyA.length) P(`- **仅旧版有**:${onlyA.join('、')}`)
165
+ if (onlyB.length) P(`- **仅新版有**:${onlyB.join('、')}`)
166
+ if (!onlyA.length && !onlyB.length) P('- 两份画面名清单一致')
167
+ P('')
168
+
169
+ /* 4. 配套文件 */
170
+ P('## 4. 配套文件差异', '')
171
+ const allPeers = [...new Set([...A.peers, ...B.peers])].sort()
172
+ P('| 文件 | 旧版 | 新版 | 差异 |')
173
+ P('|---|---|---|---|')
174
+ const peerTextDiff = []
175
+ for (const name of allPeers) {
176
+ const a = peerInfo(A.dir, A.base, name), b = peerInfo(B.dir, B.base, name)
177
+ let note = ''
178
+ if (a && b && a.texts && b.texts) {
179
+ const oa = [...a.texts].filter(x => !b.texts.has(x)), ob = [...b.texts].filter(x => !a.texts.has(x))
180
+ if (oa.length || ob.length) {
181
+ note = `文本 −${oa.length}/+${ob.length}`
182
+ peerTextDiff.push({ name, oa, ob })
183
+ }
184
+ }
185
+ P(`| ${name.replace(A.base, '<工程名>')} | ${a ? a.size.toLocaleString() : '—'} | ${b ? b.size.toLocaleString() : '—'} | ${(a && b && a.size === b.size) ? '相同' : (a && b ? `差 ${b.size - a.size}` : (a ? '仅旧版有' : '仅新版有'))}${note ? ';' + note : ''} |`)
186
+ }
187
+ P('')
188
+ for (const d of peerTextDiff) {
189
+ P(`### ${d.name.replace(A.base, '<工程名>')} 文本差异`, '')
190
+ if (d.oa.length) { P('- 仅旧版有:'); for (const s of d.oa.slice(0, 40)) P(` - \`${s}\``) }
191
+ if (d.ob.length) { P('- 仅新版有:'); for (const s of d.ob.slice(0, 40)) P(` - \`${s}\``) }
192
+ P('')
193
+ }
194
+
195
+ P('## 5. 怎么读这份报告', '')
196
+ P('- 第 1 节是**重启后显示哪个画面**的直接依据;两份值不同 ⇒ 这就是改动点。')
197
+ P('- 若起始画面指向「已删除」的画面,面板会退到其它画面 —— 这是"重启跳到别的画面"的典型成因。')
198
+ P('- 第 2/3 节确认画面本身有无增删改名;第 4 节是配套文件(项目文本/组态数据)的差异。')
199
+ P('- 报告只读源文件;需要更深的字段级差异(变量表、元件属性)时,先跑 hmismart-decode.mjs 出两侧解码产物再逐项比对。')
200
+ P('')
201
+
202
+ const outFile = path.join(outDir, 'HMI差异报告.md')
203
+ fs.writeFileSync(outFile, L.join('\n'))
204
+ console.log(`[完成] ${outFile}`)
205
+ console.log(` 起始画面:旧=${devA.startScreenId || '-'}${sa ? `(${sa.name}${sa.deleted === 'true' ? '·已删除' : ''})` : ''} 新=${devB.startScreenId || '-'}${sb ? `(${sb.name}${sb.deleted === 'true' ? '·已删除' : ''})` : ''}`)
206
+ console.log(` 画面表:旧 ${A.screens.size} 条|新 ${B.screens.size} 条;画面名:旧 ${A.names.size} 个|新 ${B.names.size} 个`)