dsh-plc-decode 0.1.5 → 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.
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * report.mjs —— 读 hmismart-decode.mjs 的输出,生成人读报告
4
+ * 用法: node report.mjs <解码输出目录> [工程名]
5
+ */
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+
9
+ const dir = path.resolve(process.argv[2])
10
+ const projName = process.argv[3] || path.basename(path.dirname(dir))
11
+ // 优先用清洗后的记录(01b);没有时退回全量(01)
12
+ const tsvFile = fs.existsSync(path.join(dir, '01b-对象记录-清洗.tsv')) ? '01b-对象记录-清洗.tsv' : '01-对象记录.tsv'
13
+ const tsv = fs.readFileSync(path.join(dir, tsvFile), 'utf8').split('\n').slice(1).filter(Boolean)
14
+
15
+ const recs = tsv.map(l => { const [off, g, ...rest] = l.split('\t'); return { off, g, name: rest.join('\t') } })
16
+ const byType = new Map()
17
+ for (const r of recs) { if (!byType.has(r.g)) byType.set(r.g, []); byType.get(r.g).push(r.name) }
18
+ const uniq = g => [...new Set(byType.get(g) || [])]
19
+ const stat = JSON.parse(fs.readFileSync(path.join(dir, '08-统计.json'), 'utf8'))
20
+
21
+ const TYPE = {
22
+ screen: 'a8d0ec5b-a448-dd4e-8cfd-1f93bab80357',
23
+ tag: '98441ece-8aaa-3841-9025-0908339aa88e',
24
+ conn: '0c0a9fed-e4f1-6d43-8fed-097fa8051506',
25
+ image: 'd65b6511-ea52-8442-af75-79c84c001e54',
26
+ tmpl: 'a1b2d381-8148-4d48-9401-867b9fdcc709',
27
+ user: 'e2a88f13-a660-734f-be15-ec7df20c04a8',
28
+ user2: 'd912bd6b-8243-fd43-84aa-4d3bfa5650ef',
29
+ confirm: 'ad962e66-7c6b-5c48-87ae-1d834a4205b8',
30
+ sysvar: 'cc1746b5-1be8-f74b-8cef-934dd946b81b',
31
+ period: 'fa8ef5c6-e7da-2d4b-984b-b1e109cf948b',
32
+ graphlist: '39578188-7014-5243-b9e0-a928e3f16129',
33
+ }
34
+
35
+ const VAR_GROUPS = [
36
+ ['PLC 直接地址', /^[QIMV]\d|^VD\d|^VW\d|^VB\d|^CD\d|^\d/],
37
+ ['锁机 / 产量限制', /锁机/],
38
+ ['伺服 / 编码器 / 位置', /伺服|编码器|旋转|原点|位置|位移|偏移/],
39
+ ['加料 / 送料 / 挡料', /加料|送料|上料|放料|挡料|整料|整平/],
40
+ ['吸盒 / 吸托 / 吸纸 / 吹气', /吸盒|吸托|吸纸|吹气|气压/],
41
+ ['封盒 / 压盒 / 顶盒 / 推料', /封盒|压盒|压托|顶盒|顶托|推托|推料|压料|压舌|推杆|飞料/],
42
+ ['喷胶', /喷胶/],
43
+ ['检测 / 剔废', /检测|测书|测托|测料|测盒|测速|测瓶|剔废|测瓶/],
44
+ ['报警 / 故障', /报警|故障|急停|缺料|缺纸|堵料|卡盒|盒子|托库/],
45
+ ['速度 / 计数 / 产量', /速度|计数|产量|清零|速比/],
46
+ ['画面 / 显示', /画面|隐藏/],
47
+ ['用户 / 密码', /用户|密码|权限/],
48
+ ['日期 / 时间', /当前[日年月]|[日月年]期/],
49
+ ['屏操作 / 模式', /屏|启动|停止|点动|手动|调试|自动|联机|复位|运行/],
50
+ ['备用', /备用/],
51
+ ]
52
+ const grouped = new Set()
53
+ const varLines = []
54
+ const tags = uniq(TYPE.tag)
55
+ for (const [label, re] of VAR_GROUPS) {
56
+ const hit = tags.filter(t => re.test(t) && !grouped.has(t))
57
+ hit.forEach(t => grouped.add(t))
58
+ if (hit.length) varLines.push(`- **${label}**(${hit.length}):${hit.join('、')}`)
59
+ }
60
+ const restTags = tags.filter(t => !grouped.has(t))
61
+ if (restTags.length) varLines.push(`- **其他**(${restTags.length}):${restTags.join('、')}`)
62
+
63
+ const screens = uniq(TYPE.screen)
64
+ const images = uniq(TYPE.image).filter(n => !/\.(Internal|SIMATIC S7)/.test(n))
65
+ const auths = uniq(TYPE.user2).filter(n => !/LastUsedShortId|^[a-f0-9-]{16,}/.test(n))
66
+ const compTypes = Object.keys(TYPE).length
67
+ const compLines = []
68
+ for (const [g, names] of byType) {
69
+ const u = [...new Set(names)]
70
+ if (u.length >= 3 && u.filter(n => /^(按钮|文本域|IO 域|开关|矩形|线|图形|报警视图|日期时间域|符号)/.test(n)).length / u.length > 0.5) {
71
+ compLines.push(`| ${g} | ${u.length} | ${u.slice(0, 24).join('、')}${u.length > 24 ? ' …' : ''} |`)
72
+ }
73
+ }
74
+
75
+ const md = `# .hmismart 解码报告 — ${projName}
76
+
77
+ > 源文件:\`${stat.file}\`
78
+ > 大小 ${stat.size.toLocaleString()} 字节(${(stat.size / 1048576).toFixed(2)} MB)
79
+ > 产物目录:\`${dir}\`
80
+
81
+ ## 0. 结论
82
+
83
+ 这份文件**不是压缩包、也没有加密**——它是西门子 WinCC flexible SMART 的**工程数据库**(单文件)。里面明文存着这台设备触摸屏的完整组态:
84
+
85
+ | 内容 | 数量 |
86
+ |---|---|
87
+ | 画面 | ${screens.length} 项(含模板/文件夹 ${screens.filter(s => /Folder|Template/i.test(s)).length} 项) |
88
+ | HMI 变量 | ${tags.length} 个 |
89
+ | 通讯连接 | ${uniq(TYPE.conn).length} 个 |
90
+ | 画面元件 | ${compLines.length} 类(按钮/文本域/IO域/开关/矩形/线/图形视图/报警视图/日期时间域) |
91
+ | 图片资源 | ${images.length} 项 |
92
+ | 内嵌数据表 | ${stat.tables.length} 张 |
93
+ | 压缩块 | ${stat.blocks} 个 → 解压 ${(stat.blockBytes / 1048576).toFixed(2)} MB |
94
+
95
+ ## 1. 文件格式(逆向结果)
96
+
97
+ **魔数**:\`01 0F 00 00 08 02 00 00\`|**页结构**:4096 字节 × ${stat.size / 4096} 页(严格页对齐)
98
+
99
+ 文件里同时存在三种存储形态:
100
+
101
+ 1. **明文对象记录**(对象名、变量名、连接名就在这)
102
+ 结构:\`36\` + UUID(16 字节) + 若干字段 + 名称(UTF-16LE)
103
+ UUID 是**对象类别**,同一类对象共用一个 UUID(本文件共 ${stat.guidTypes} 种)。
104
+ 2. **压缩块**:\`1f 8b 08 00 00 00 00 00 04 00\`(gzip 头)+ **裸 deflate**,**没有 CRC/ISIZE 尾部**
105
+ ——所以标准 \`gunzip\` 打不开(只能解出 112/${stat.blocks} 个),必须用 \`inflateRaw\` 才能全解。
106
+ 压缩块里是对象的"属性名 + 属性值"序列,以及软件自带的多语言系统消息。
107
+ 3. **内嵌数据库 DDL**:${stat.tables.length} 张表的建表脚本(\`03-建表脚本.sql\`),
108
+ 说明工程数据的逻辑模型是关系表(HmiScreenTable / HmiTagTable / HmiConnectionTable / HmiScreenItemTable …)。
109
+
110
+ **编码**:字符串为 UTF-16LE;名称后面紧跟记录字段,会粘 1~2 个生僻字,已按工程常用字表裁剪。
111
+
112
+ ## 2. 工程内容
113
+
114
+ ### 2.1 画面(${screens.length} 项)
115
+
116
+ ${screens.map(s => `- ${s}`).join('\n')}
117
+
118
+ ### 2.2 HMI 变量(${tags.length} 个)
119
+
120
+ ${varLines.join('\n')}
121
+
122
+ ### 2.3 通讯连接
123
+
124
+ ${uniq(TYPE.conn).map(s => `- ${s}`).join('\n') || '(无)'}
125
+
126
+ 连接参数(从压缩块的属性包里解出):
127
+ \`\`\`
128
+ LocDeviceName = 192.168.2.10 (PLC 侧地址)
129
+ SubnetMask = 255.255.0.0
130
+ InterfaceType = ETHERNET
131
+ LocAddressType= TCPIP
132
+ DevNameID = S7ONLINE
133
+ \`\`\`
134
+
135
+ ### 2.4 画面元件
136
+
137
+ | 类型 UUID | 唯一名 | 名称 |
138
+ |---|---|---|
139
+ ${compLines.join('\n')}
140
+
141
+ ### 2.5 模板元件
142
+
143
+ ${uniq(TYPE.tmpl).slice(0, 60).join('、')}
144
+
145
+ ### 2.6 图片资源
146
+
147
+ ${images.join('、')}
148
+
149
+ ### 2.7 用户与权限
150
+
151
+ - 用户:${uniq(TYPE.user).join('、')}
152
+ - 授权组:${auths.length ? auths.join('、') : '(未解出可读名)'}
153
+
154
+ ### 2.8 报警配置
155
+
156
+ - 确认组(${uniq(TYPE.confirm).length}):${uniq(TYPE.confirm).join('、')}
157
+ - 报警类别 / 分组在项目树节点里:${uniq('b0e0cad9-a3bb-074d-a9cf-296d0573e254').filter(n => /报警/.test(n)).join('、')}
158
+
159
+ ### 2.9 画面更新周期
160
+
161
+ ${uniq(TYPE.period).join('、')}
162
+
163
+ ### 2.10 软件自带内容(非本工程)
164
+
165
+ - 多语言系统消息 ${stat.zhMsgs} 条(中文,见 \`06-系统消息_中文.txt\`)
166
+ - Smart 主题/样式名(Smart-chart / Smart-ghost / Smart-gradient …)
167
+ - 图形列表、字体列表、系统函数清单
168
+
169
+ ## 3. 产物清单
170
+
171
+ | 文件 | 内容 |
172
+ |---|---|
173
+ ${Object.keys(stat.outputs).map(k => `| \`${k}\` | ${(stat.outputs[k] / 1024).toFixed(0)} KB |`).join('\n')}
174
+
175
+ ## 4. 已知边界
176
+
177
+ - 画面内元件的**精确坐标与逐条属性**未逐字段还原:它们在压缩块里以"属性名+值"序列存放,已整体导出(\`04-解压块-工程数据.txt\`),但未做字段级重建。
178
+ - 少量名称尾部残留 1~2 个噪声字符(记录结构紧邻所致),以 \`01-对象记录.tsv\` 的原始值为准。
179
+ - 图片资源的**二进制本体**未单独导出成图片文件。
180
+ - 另有 500+ 个未归类 UUID,多为软件内部结构或二进制误命中(\`02-对象分类.md\` 中标为"其他/待定""疑似二进制区")。
181
+
182
+ ## 5. 复跑
183
+
184
+ \`\`\`bash
185
+ node hmismart-decode.mjs <工程文件.hmismart> [输出目录]
186
+ node report.mjs <解码输出目录> [工程名]
187
+ \`\`\`
188
+ 只读源文件,不改动任何原始工程。
189
+ `
190
+ fs.writeFileSync(path.join(dir, 'HMI解码报告.md'), md)
191
+ console.log(`已生成 ${path.join(dir, 'HMI解码报告.md')}(${(Buffer.byteLength(md) / 1024).toFixed(1)} KB)`)
192
+ console.log(`画面 ${screens.length}|变量 ${tags.length}|连接 ${uniq(TYPE.conn).length}|图片 ${uniq(TYPE.image).length}|元件类型 ${compLines.length}`)
@@ -0,0 +1,140 @@
1
+ // mcgs-decode.mjs —— MCGS / LSTP Studio 工程解码(.MCP / .MCP.mcgs)
2
+ //
3
+ // 格式:文件头 `Standard Jet` = Microsoft Access 数据库(Jet 4.0)。
4
+ // 用 32 位 PowerShell 的 OleDb 读(64 位宿主没有 Jet 驱动)。
5
+ // 关键表:WndUser 画面 / Data 变量 / Alarm 报警 / Stgy 策略 / WndControl 控件。
6
+ //
7
+ // 用法:node mcgs-decode.mjs <工程.MCP> [输出目录]
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import os from 'node:os'
11
+ import { execFileSync } from 'node:child_process'
12
+
13
+ const PS32 = 'C:\\Windows\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe'
14
+ const TABLES = [
15
+ ['WndUser', '画面', ['iID', 'strName', 'strNote']],
16
+ ['Data', '变量', ['iDataID', 'strName', 'strNote', 'strUnit', 'iDataType', 'strInitValue']],
17
+ ['Alarm', '报警', ['iAlarmID', 'strNote', 'iPriority']],
18
+ ['Stgy', '策略', ['iStgyID', 'strStgyName', 'strStgyComment']],
19
+ ['WndControl', null, null],
20
+ ]
21
+
22
+ function readTables(dbPath) {
23
+ const body = TABLES.map(([t, , cols]) => {
24
+ // 注意:这里的 ${...} 必须全部在 Node 侧展开 —— 写进 ps1 后 PowerShell 会把 ${x} 当变量插值,
25
+ // 之前就是因为它把 SQL 里的 ${sel}/${t} 吃掉,导致 “'' is not a valid name”。
26
+ const sel = cols ? cols.map((c) => `[${c}]`).join(',') : '*'
27
+ const sql = `SELECT ${sel} FROM [${t}]`
28
+ const cnt = `SELECT COUNT(*) FROM [${t}]`
29
+ return `
30
+ try {
31
+ $c = $cn.CreateCommand(); $c.CommandText = "${cnt}"; $n = $c.ExecuteScalar()
32
+ $c = $cn.CreateCommand(); $c.CommandText = "${sql}"
33
+ $rd = $c.ExecuteReader()
34
+ $rows = @()
35
+ while ($rd.Read()) {
36
+ $o = [ordered]@{}
37
+ for ($i=0; $i -lt $rd.FieldCount; $i++) { $o[$rd.GetName($i)] = $rd.GetValue($i) }
38
+ $rows += [pscustomobject]$o
39
+ }
40
+ $rd.Close()
41
+ $out['${t}'] = [pscustomobject]@{ count = $n; rows = $rows }
42
+ } catch { $out['${t}'] = [pscustomobject]@{ count = -1; error = $_.Exception.Message } }
43
+ `
44
+ }).join('\n')
45
+ const ps = `
46
+ $ErrorActionPreference = 'Stop'
47
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
48
+ $out = [ordered]@{}
49
+ $cn = New-Object System.Data.OleDb.OleDbConnection("Provider=Microsoft.Jet.OLEDB.4.0;Data Source=${dbPath.replace(/"/g, '""')};")
50
+ $cn.Open()
51
+ ${body}
52
+ $cn.Close()
53
+ $out | ConvertTo-Json -Depth 6 -Compress
54
+ `
55
+ const tmp = path.join(os.tmpdir(), `mcgs-${Date.now()}.ps1`)
56
+ // PowerShell 5.1 读 .ps1 时按 ANSI 解析中文,必须带 BOM 才认 UTF-8
57
+ fs.writeFileSync(tmp, '\ufeff' + ps, 'utf8')
58
+ try {
59
+ const raw = execFileSync(PS32, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmp], {
60
+ encoding: 'utf8', maxBuffer: 64 << 20, timeout: 180000,
61
+ })
62
+ fs.rmSync(tmp, { force: true })
63
+ return JSON.parse(raw.trim() || '{}')
64
+ } catch (e) {
65
+ fs.rmSync(tmp, { force: true })
66
+ throw new Error(`读取 MCGS 数据库失败:${String(e.message).split('\n')[0]}`)
67
+ }
68
+ }
69
+
70
+ function main() {
71
+ const file = process.argv[2]
72
+ if (!file) {
73
+ console.error('用法: node mcgs-decode.mjs <工程.MCP> [输出目录]')
74
+ process.exit(2)
75
+ }
76
+ const abs = path.resolve(file)
77
+ if (!fs.existsSync(abs)) { console.error(`文件不存在: ${file}`); process.exit(2) }
78
+ const head = fs.readFileSync(abs).subarray(4, 19).toString('binary')
79
+ if (!head.includes('Standard Jet')) {
80
+ console.error(`不是 MCGS/Access 工程(文件头: ${head.replace(/[^\x20-\x7e]/g, '.')})`)
81
+ process.exit(3)
82
+ }
83
+ const outDir = process.argv[3] ? path.resolve(process.argv[3])
84
+ : path.join(path.dirname(abs), '解码归档', path.basename(abs, path.extname(abs)))
85
+ fs.mkdirSync(outDir, { recursive: true })
86
+
87
+ const data = readTables(abs)
88
+ const L = []
89
+ L.push(`# ${path.basename(abs)} · MCGS 解码`)
90
+ L.push('')
91
+ L.push(`- 源文件: ${path.basename(abs)}(${(fs.statSync(abs).size / 1024).toFixed(0)} KB,Access/Jet 数据库)`)
92
+ const wnd = data.WndUser?.rows || []
93
+ L.push(`- 规模: 画面 ${wnd.length} / 变量 ${data.Data?.count ?? '?'} / 报警 ${data.Alarm?.count ?? '?'} / 策略 ${data.Stgy?.count ?? '?'}`)
94
+ L.push('')
95
+ L.push('## 画面')
96
+ L.push('')
97
+ L.push('| ID | 画面名 | 备注 |')
98
+ L.push('|---|---|---|')
99
+ for (const r of wnd) L.push(`| ${r.iID} | ${r.strName || ''} | ${(r.strNote || '').replace(/\|/g, '/')} |`)
100
+ const vars = data.Data?.rows || []
101
+ if (vars.length) {
102
+ L.push('')
103
+ L.push('## 变量')
104
+ L.push('')
105
+ L.push('| ID | 名字 | 注释 | 单位 | 类型 | 初值 |')
106
+ L.push('|---|---|---|---|---|---|')
107
+ for (const r of vars) L.push(`| ${r.iDataID} | ${r.strName || ''} | ${(r.strNote || '').replace(/\|/g, '/')} | ${r.strUnit || ''} | ${r.iDataType ?? ''} | ${r.strInitValue ?? ''} |`)
108
+ }
109
+ const alms = data.Alarm?.rows || []
110
+ if (alms.length) {
111
+ L.push('')
112
+ L.push('## 报警')
113
+ L.push('')
114
+ L.push('| ID | 内容 | 优先级 |')
115
+ L.push('|---|---|---|')
116
+ for (const r of alms) L.push(`| ${r.iAlarmID} | ${(r.strNote || '').replace(/\|/g, '/')} | ${r.iPriority ?? ''} |`)
117
+ }
118
+ const stg = data.Stgy?.rows || []
119
+ if (stg.length) {
120
+ L.push('')
121
+ L.push('## 策略(脚本)')
122
+ L.push('')
123
+ L.push('| ID | 名字 | 注释 |')
124
+ L.push('|---|---|---|')
125
+ for (const r of stg) L.push(`| ${r.iStgyID} | ${r.strStgyName || ''} | ${(r.strStgyComment || '').replace(/\|/g, '/')} |`)
126
+ }
127
+ const errs = Object.entries(data).filter(([, v]) => v && v.error)
128
+ if (errs.length) {
129
+ L.push('')
130
+ L.push('## 读取失败的表')
131
+ for (const [k, v] of errs) L.push(`- ${k}: ${v.error}`)
132
+ }
133
+ const report = L.join('\n') + '\n'
134
+ fs.writeFileSync(path.join(outDir, '解码报告.md'), report, 'utf8')
135
+ fs.writeFileSync(path.join(outDir, '表数据.json'), JSON.stringify(data, null, 1), 'utf8')
136
+ console.log(`OK ${path.basename(abs)} → ${outDir}`)
137
+ console.log(` 画面 ${wnd.length} / 变量 ${data.Data?.count ?? '?'} / 报警 ${data.Alarm?.count ?? '?'} / 策略 ${data.Stgy?.count ?? '?'}`)
138
+ }
139
+
140
+ main()
@@ -0,0 +1,107 @@
1
+ // nb-decode.mjs —— 欧姆龙 NB-Designer 工程解码(.nbp + HMI*/HMI*.whe)
2
+ //
3
+ // 格式:两者都是纯 XML(不是二进制、不是压缩)。
4
+ // .nbp = 工程索引:PLC 连接、串口参数、HMI 引用、地址标签、PLC 控制对象
5
+ // HMI*.whe = 画面内容:<Window ProjectID Name ...> 每个画面,<Part Type Name> 每个控件,
6
+ // <Address ...><AddressType>LW</AddressType><AddressValue>9190</AddressValue>
7
+ // 用法:node nb-decode.mjs <工程.nbp> [输出目录]
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+
11
+ function attr(s, name) {
12
+ const m = new RegExp(`\\b${name}="([^"]*)"`).exec(s)
13
+ return m ? m[1] : ''
14
+ }
15
+ function num(block, tag) {
16
+ const m = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(block)
17
+ return m ? m[1] : ''
18
+ }
19
+
20
+ function main() {
21
+ const file = process.argv[2]
22
+ if (!file) { console.error('用法: node nb-decode.mjs <工程.nbp> [输出目录]'); process.exit(2) }
23
+ const abs = path.resolve(file)
24
+ if (!fs.existsSync(abs)) { console.error(`文件不存在: ${file}`); process.exit(2) }
25
+ const head = fs.readFileSync(abs, 'utf8').slice(0, 200)
26
+ if (!head.includes('<?xml')) { console.error('不是 NB-Designer 工程(.nbp 应为 XML)'); process.exit(3) }
27
+
28
+ const base = path.dirname(abs)
29
+ const outDir = process.argv[3] ? path.resolve(process.argv[3]) : path.join(base, '解码归档', path.basename(abs, '.nbp'))
30
+ fs.mkdirSync(outDir, { recursive: true })
31
+
32
+ const nbp = fs.readFileSync(abs, 'utf8')
33
+ const projName = attr(nbp.slice(0, 300), 'Name') || path.basename(abs, '.nbp')
34
+ const plcs = [...nbp.matchAll(/<PLC\s+([^>]*)>/g)].map((m) => ({ raw: m[1], name: attr(m[1], 'Name'), sys: attr(m[1], 'SystemID') }))
35
+ const hmis = [...nbp.matchAll(/<HMI\s+URL="([^"]+)"/g)].map((m) => m[1].replace(/\\/g, '/'))
36
+ const links = [...nbp.matchAll(/<Connector Name="([^"]*)"[^>]*Type="(\d)"/g)].map((m) => ({ name: m[1], type: m[2] }))
37
+
38
+ // 画面与控件:遍历每个 HMI 的 .whe
39
+ const windows = []
40
+ for (const rel of hmis) {
41
+ const whePath = path.join(base, rel)
42
+ if (!fs.existsSync(whePath)) continue
43
+ const whe = fs.readFileSync(whePath, 'utf8')
44
+ for (const wm of whe.matchAll(/<Window\s+ProjectID="(\d+)"\s+Name="([^"]*)"([^>]*)>/g)) {
45
+ const pid = wm[1], name = wm[2]
46
+ const start = wm.index
47
+ const next = whe.indexOf('<Window ', start + 10)
48
+ const block = whe.slice(start, next > 0 ? next : undefined)
49
+ const parts = []
50
+ for (const pm of block.matchAll(/<Part\s+Type="([^"]*)"\s+[^>]*?Name="([^"]*)"([\s\S]{0,4000}?)<\/Part>/g)) {
51
+ const type = pm[1], pname = pm[2], body = pm[3]
52
+ const addrType = num(body, 'AddressType')
53
+ const addrVal = num(body, 'AddressValue')
54
+ parts.push({ type, name: pname, addr: addrType ? `${addrType}${addrVal}` : '' })
55
+ }
56
+ windows.push({ pid: Number(pid), name, file: rel, parts })
57
+ }
58
+ }
59
+ windows.sort((a, b) => a.pid - b.pid)
60
+
61
+ const L = []
62
+ L.push(`# ${projName} · NB-Designer 解码`)
63
+ L.push('')
64
+ L.push(`- 源文件: ${path.basename(abs)}(${(fs.statSync(abs).size / 1024).toFixed(1)} KB,XML)`)
65
+ L.push(`- 规模: PLC 连接 ${plcs.length} / 画面 ${windows.length} / 控件 ${windows.reduce((n, w) => n + w.parts.length, 0)}`)
66
+ if (hmis.length) L.push(`- 画面文件: ${hmis.join(', ')}`)
67
+ L.push('')
68
+ L.push('## PLC 连接')
69
+ L.push('')
70
+ L.push('| 名字 | 系统 ID |')
71
+ L.push('|---|---|')
72
+ for (const p of plcs) L.push(`| ${p.name} | ${p.sys} |`)
73
+ if (links.length) {
74
+ L.push('')
75
+ L.push('## 连接器')
76
+ L.push('')
77
+ L.push('| 名字 | 类型 |')
78
+ L.push('|---|---|')
79
+ for (const l of links) L.push(`| ${l.name} | ${l.type} |`)
80
+ }
81
+ L.push('')
82
+ L.push('## 画面与控件')
83
+ L.push('')
84
+ L.push('| 画面 ID | 画面名 | 控件数 | 控件类型分布 |')
85
+ L.push('|---|---|---|---|')
86
+ for (const w of windows) {
87
+ const byType = {}
88
+ for (const p of w.parts) byType[p.type] = (byType[p.type] || 0) + 1
89
+ const dist = Object.entries(byType).map(([k, v]) => `${k}×${v}`).join(',') || '—'
90
+ L.push(`| ${w.pid} | ${w.name} | ${w.parts.length} | ${dist} |`)
91
+ }
92
+ const withAddr = windows.flatMap((w) => w.parts.filter((p) => p.addr).map((p) => ({ w: w.name, ...p })))
93
+ if (withAddr.length) {
94
+ L.push('')
95
+ L.push('## 带地址的控件(前 60)')
96
+ L.push('')
97
+ L.push('| 画面 | 控件 | 类型 | 地址 |')
98
+ L.push('|---|---|---|---|')
99
+ for (const p of withAddr.slice(0, 60)) L.push(`| ${p.w} | ${p.name} | ${p.type} | ${p.addr} |`)
100
+ }
101
+ fs.writeFileSync(path.join(outDir, '解码报告.md'), L.join('\n') + '\n', 'utf8')
102
+ fs.writeFileSync(path.join(outDir, '画面控件.json'), JSON.stringify({ projName, plcs, hmis, windows }, null, 1), 'utf8')
103
+ console.log(`OK ${projName} → ${outDir}`)
104
+ console.log(` PLC ${plcs.length} / 画面 ${windows.length} / 控件 ${windows.reduce((n, w) => n + w.parts.length, 0)}`)
105
+ }
106
+
107
+ main()
@@ -0,0 +1,93 @@
1
+ # NB 系列触摸屏文本库翻译(.nbp / .whe)
2
+
3
+ `拓宇`这类用 NB 软件做的屏,工程是 `.nbp`(主工程 XML)+ `HMI0\HMI0.whe`(画面 XML),
4
+ 文本库另导出成 `TextLib.csv`。三个文件都是纯文本,可直接解析,不需要开软件。
5
+
6
+ ## 一、认格式
7
+
8
+ - `.nbp`:`<NBProject DBVersion="...">`,画面内容不在这里,只存工程设置与 PLC 配置。
9
+ - `.whe`:`<HMI>` → `<Frames>` → `<Window ProjectID Name>` → `<Parts>` → `<Part Type=...>` → `<Lable>`。
10
+ 22 个画面,系统画面名是英文(Login Window、HEX/ASCII/NUM Keyboard、Confirm Action Window 等)。
11
+ - `TextLib.csv`:UTF-16LE + BOM,**LF** 换行,四列制表符+双引号,条目之间空行。
12
+ 结构是每组:`"Name:"\t"条目名"` / `"Status:"\t"状态数"` / `"Language"\t"Language1"\t"Language2"\t"Language3"` / 若干数据行。
13
+ - 数据行里的 `$_return_$$_enter_$` 是软件内的换行控制符(不是字面量),原版英文靠它分行。
14
+
15
+ ## 二、走库怎么判(最容易判错的一处)
16
+
17
+ 元件文字全在 `<Lable>` 里,两个属性决定:
18
+
19
+ - `UseTextLibrary="Yes"` / `"No"`
20
+ - `LibraryTextName="条目名"`(对应 CSV 的 `"Name:"`,不是中文列)
21
+
22
+ **勾了库 ≠ 走库**:必须再核 `LibraryTextName` 是否真的在库条目名里。条目不存在时,软件回退显示
23
+ `<Font>` 里内嵌的字,屏上看着正常、切换语种却不变——和没走库一个效果,肉眼看不出来。
24
+
25
+ 判定顺序:
26
+
27
+ 1. `UseTextLibrary=Yes` 且库里有该条目 → 正常走库,跳过。
28
+ 2. `UseTextLibrary=Yes` 但库里查无此条目 → **假走库**,当作翻不到处理。
29
+ 3. `UseTextLibrary=No` → 未走库。文字在 `<Font>文字</Font>`;多状态元件每个 `<State>` 一组。
30
+
31
+ ## 三、扫什么
32
+
33
+ - 遍历**所有** `<Lable>`:静态标签、按钮、开关、指示灯的文字都在这,别只扫一种元件。
34
+ - 系统画面(画面名是英文的那批)不管。
35
+ - IO 地址标注(纯编号如 `100.03`、`0.9`)不管。
36
+ - 报警单独看,见第六节。
37
+
38
+ ## 四、翻译口径
39
+
40
+ - 字典优先:中文命中字典的逐字照用,不改写、不压缩、不换同义词。
41
+ - 字典没有的:英文保留原版英文(不是我造的),第三语种留空,绝不自行翻译。
42
+ - 字典备注列写「请删除」的条目:不翻、保持原样,也不进缺译表。
43
+ - 原文本身是英文(中文列不含汉字)的条目:三语填同一个值。
44
+ - 纯英文且没勾库的控件文字:不管。
45
+ - 原版英文含 `$_return_$$_enter_$` 时:译文单行显示宽度超过原版最长行才按原行数在空格处切分
46
+ (全角字符按 2 计宽),不改变文字内容。
47
+
48
+ ## 五、缺译清单
49
+
50
+ - 固定三列:中文 / 英文 / 韩语,没有的留空;按中文去重(一源一译)。
51
+ - 内容 = 库内缺译(英文空或第三语种空)+ 翻不到的控件文字(未勾库的 + 假走库的)。
52
+ - 已在库内缺译里出现的中文不重复列。
53
+
54
+ ## 六、报警
55
+
56
+ - `.nbp` 里 `<AlarmObjects>` / `<EventObjects>` / `<Event>` 都是空节点 = 没有自定义报警/事件配置。
57
+ - 屏上的报警通常是 Word Lamp 读某个文本库条目的状态字(状态显示类)。要核条目 `Status` 是否
58
+ 覆盖元件 `StateCount`,不够时多出来的状态会显示空白。
59
+
60
+ ## 七、切了语种显示不出目标语言(韩文/日文/俄文)
61
+
62
+ 文字有没有、走不走库都正常,但切过去画面上不出字——按下面四处依次断,四处都通才显示:
63
+
64
+ 1. **语种槽有没有字**:`.nbp` 的 `<TextObjects>/<LableText>/<States>/<State>` 下 `<Text>` 的个数=语种数,
65
+ 第 N 个就是语种 N。某语种槽空=切过去空白。
66
+ 2. **字体有没有该语种的字形**(最常见的一处):
67
+ - 语种默认字体在 `.nbp` 的 `<DefaultLangFontInfo>`,几条 `<VectorFont>` 就是几个语种;
68
+ - 元件标签的三语字体在 `.whe` 的 `<Lable>/<Text>/<VectorFontList>`,三个 `<VectorFont>` 按语种顺序;
69
+ - 这两处字体名一样、且该字体不含目标语种字形,就画不出来(软件用方格或空白顶上)。
70
+ - 软件自带向量字体清单在 `<安装目录>\vectorfonts.xml`,按语种给默认字体:
71
+ **DFHEI5=简体中文(20976 汉字、0 谚文)**、DFNB-TW=繁体、DFNB-EN=西文、
72
+ **DFNB-KR=韩文(2350 个韩文音节)**、DFNB-JP / DFGNB-JP / DFPNB-JP=日文。
73
+ - 核对办法:读 ttf 的 cmap 数 U+AC00–U+D7A3 有没有字形,比看字体名可靠。
74
+ 3. **下载包里带没带**:编译输出 `tar\HMI0\font_*.ttf` 是按工程实际用到的字裁出来的子集。
75
+ 里面没有目标语种字形,屏上一样不显示——改完字体必须重新编译下载。
76
+ 4. **切语种入口**:运行时靠 LW9130 切语种(`LW9130=语种序号−1`,0 起);切语种的画面上要有对应按钮,
77
+ 否则屏上没法切过去。
78
+
79
+ 改字体:
80
+
81
+ - 入口:【HMI 属性】→【触摸屏扩展属性】→【语种设置】(文本库窗口里也能进)→ 当前编辑语种 →
82
+ 字体类型选矢量字体 → 字体属性里选该语种字体。
83
+ - 【语种设置】**不会**改已有元件标签的字体。两种刷新法:
84
+ ① 逐个元件把【标签】页「使用文本库」取消勾选再勾上;
85
+ ② 把工程当前编辑语种切到该语种,全选元件,用字体工具栏统一设字体(工具栏改的是当前状态、当前语种)。
86
+ - 韩文字形 2350 个,改完屏端字体体积会明显变大,下载前留意屏的存储余量。
87
+
88
+ ## 八、改文件
89
+
90
+ - 只改数据行第 3、4 列(英文、第三语种),中文列与全部结构行原样;写回 UTF-16LE+BOM、LF。
91
+ - 改前先把原件备份到独立目录(保留原相对路径),再动导出表。
92
+ - 目标 xlsx 正被 Excel 打开时:用 COM 连活动实例(`Get-ActiveObject`),**先查 `Saved=True`** 再改;
93
+ 有未保存改动就不动,问用户。改完 `Workbook.Save()`,不要 `Quit`。
@@ -0,0 +1,96 @@
1
+ # wincc-text-i18n-workflow —— 西门子触摸屏项目文本翻译
2
+
3
+ `Screens.xls / Others.xls / Messages.xls` 是 WinCC flexible(SMART)把工程文字按「画面 / 其它 / 报警」三处导出成的 Excel。英文列多半是半成品:有的空着,有的还是中文,更多是**串位**——这类表不能当成"填空",得当校对。
4
+
5
+ ## 一、格式契约(动之前先认清这四条)
6
+
7
+ - 第 1 行 `$_Languages`,第 2 行是语言名(第 1 列=源语言,第 2 列=目标语言),第 3 行 `$_Typ(...)`,再几行 `//` 注释。
8
+ - **第 1 列是匹配键**:导入时按它回工程里找对应文本。改一个字,那一行就对不上、白改。所以只改第 2 列。
9
+ - 文本通常被 `<text>…</text>` 包着;**换行是真换行**(Excel 单元格里 Alt+Enter 那种),不是字面 `\n`。映射文件里用 `\n` 表示,脚本读入时还原。
10
+ - 目标列出现 `$_ignore`:这句源文本在工程里已经有多个不同译文,导出时被替换掉了。**它不是"不用翻",照样译、照样填进目标列**(空文本 `<text />`、纯符号/缩写除外)。导入时软件若仍按多译文冲突跳过,那几处屏上才不会变,这时才需要进工程手动统一。留着 `$_ignore` 交给用户=表里还是中文,等于没翻。
11
+
12
+ ## 二、四步
13
+
14
+ 1. `scan`:出「待译清单.tsv」+「分型报告.md」。
15
+ 2. 逐条出译文,写成 `map.tsv`(一列中文原文、一列英文,制表符分隔)。
16
+ 3. `apply`:回写目标列,出**同扩展名**.xls。
17
+ 4. `check`:键列没动、没有未译、没有残留 `$_ignore`、没有串位(源无中文而译文更长)、没有一源多译。
18
+
19
+ 工具:`wincc-text-i18n`,`action=scan | apply | check`。
20
+
21
+ ## 三、分型只解决一半
22
+
23
+ scan 判得出来的是:空译、中文残留、`$_ignore`、非中文同形(编号/日期格式)、**编号行挂词**——源文本不含中文(纯编号/代号)而译文更长的行。这一类就是串位:别行的功能词落到这一行了,按「源只有编号,译文也只留编号」处理;日期格式串(`yyyy-M-d H:mm:ss`)两列本来就不一样,不算。
24
+
25
+ 判不出来的仍是错译:中文对得上、译文却是另一个意思的那种,必须逐行看一遍。错译常见的三种样子:
26
+
27
+ - 同类多行共用一句:`Q80~Q87` 全写 `Q15 Jet Glue 1`,`I80~I87` 全写 `I20`。
28
+ - 上一行的话落到下一行:`Q20~Q27` 写着 `Q0 / Q1 / Q2 / Q3 Reject…`,那是 Q3~Q7 的。
29
+ - 明显张冠李戴:`拓宇LOOG` → `Air pressure alarm Off`,`返回` → `Adjustment Screen`,`中性` → `Air pressure alarm On`。
30
+
31
+ ## 四、几条判据
32
+
33
+ - **字典优先于模型译法**:用户给了字典文件(中文→英文的对照表)时,字典里有的条目逐字照用——不为词形统一而改写、不为屏宽而压缩、不换同义词;只有字典里没有的条目才自行翻译。字典里没有的条目,外语撑不住控件时按控件宽度切成多行(见入口技能「外语撑破控件就分段」)。字典自身可能一词多形(`Bag Opening Servo` 与 `Bag Open Servo Not Reset` 并存),按条目各照原样,不做全局统一。写回 XML 文本节点时 `&`/`<`/`>` 用实体(`&amp;` 等),导入后还原成原字符。
34
+ - **以中文为准**。英文顺不顺口是次要的,和中文对得上才要紧。
35
+ - 用户给的字典文件可能一表多语且列位错开(实测 `HMI information .xls`:中文在 A、英文在 B、越南语前半段在 G、报警段错到 K),取某语种时两列都要读,别只看一列就下"字典没有"的结论。
36
+ - **一源一译**:同一句中文,全表只能有一个英文。同一文本两种译法,导回工程就产生多译文,下次再导出又变成 `$_ignore`,越改越乱。
37
+ - **I/O 点位中文只写编号的,英文也只写编号**。别拿英文列里的话反推功能——那多半是别行串过来的。中文写了功能的(`Q5剔废`),才补功能。
38
+ - 历史用词能用就不动(`Coder` 不改 `Encoder`),免得现场对不上。
39
+ - 型号、电话、日期格式、公司名:原样,不翻译。
40
+ - 地址这类改过的信息,以中文那一条为准重译,英文里常留着旧厂址。
41
+
42
+ ## 五、术语表(这台包装机的固定译法)
43
+
44
+ | 中文 | 英文 |
45
+ |---|---|
46
+ | 工位 | Station |
47
+ | 位置 | Position |
48
+ | 吸盒 | Suck carton |
49
+ | 走盒 | Carton feed |
50
+ | 顶盒 | top box |
51
+ | 联机 | Online |
52
+ | 剔废 | Reject |
53
+ | 堵料 | Jam |
54
+ | 飞料 | Flying material |
55
+ | 整料 | whole material |
56
+ | 加料 | Feeding |
57
+ | 测料 | Product detection |
58
+ | 测盒 | Carton detection |
59
+ | 测高 | Height detection |
60
+ | 喷胶 | Glue spray |
61
+ | 备用 | Backup |
62
+ | 缺盒 | Carton shortage |
63
+ | 缺纸 | Paper lack |
64
+ | 光电 | sensor |
65
+
66
+ 一句里的拼法:`xxx Station`/`xxx Position` 与中文的「xxx工位/xxx位置」一一对应;`xxx开/关` 写成两行 `xxx` + `On`/`Off`,与源里的真换行对齐。
67
+
68
+ ## 六、出表细节
69
+
70
+ - **产物写回源文件同路径、同名覆盖**,不要新建「翻译_项目文本」之类的目录另存——用户按原路径找文件,挪走或改名他就找不到。覆盖前先备份原件(项目侧放工作区 `_过程产物\`,不在源目录堆 `.bak`)。附加件(对照表等)放 `_过程产物`,不污染源目录。
71
+ - **只改目标单元格,别整表重写**:`xlwt` 重建整表会吞行(实测 394 行写完剩 393,之后整表错位),也会丢掉别的列。`apply` 默认走 Excel COM:复制源表 → 只改变化的格子(PowerShell 调 `Excel.Application`,不需要 pywin32)→ `Save`,行数、列数、格式、其他列全原样;没有 Excel 才退到 xlwt,且写完回读核对行数与键列,对不上直接报错。
72
+ - 必须出 **.xls**:软件的导入对话框按扩展名过滤,给 .xlsx 会选不到。
73
+ - 新版导出表会带第 3 列元件路径(`\设备_1\HmiScreenFolder\…`),能定位这句文字在哪个画面——别丢。
74
+ - 写 `.xls` 首选 Excel COM(`apply` 内置,Windows 有 Excel 就能用),无 Excel 时用 `xlwt`(`<python> -m pip install xlwt`);读 .xls 借 LibreOffice Kit 转 xlsx。LibreOffice Kit 只能 xls→xlsx,不能 xlsx→xls,别拿它当写 .xls 的退路。
75
+ - 空列照写:原表有几列,产物就几列。
76
+
77
+ ## 七、交付前必须交代的一件事:导入≠保存
78
+
79
+ 项目文本导入只改内存里的工程,**不保存工程就等于没导**——关掉重开就回到上次保存的版本,屏上还是中文。判断有没有真写进去,看同目录 `.hmismart` 的修改时间:导入并保存后会变成刚才的时间,不变就是没保存。
80
+
81
+ **复核要拿工程重新导出的表来核,不能核自己手里那份。** 自己的表里全对,只说明表对;工程里对没对,要让它自己导一份出来比。工程重新导出时,第 5 行的导出时间注释会变(`//$_导出…`),比对键列时把这类注释行排除。
82
+
83
+ 所以交付话术里固定三步:导入 → **保存工程** → 点「刷新」复核。
84
+ 另外两个会让人误判的点:
85
+
86
+ - 项目文本列表打开后不自动刷新,导入完要手动点「刷新」才看到新值。
87
+ - 工程里存在多译文冲突时,导出表会多出一行 `//至少一个文本存在多种不同的翻译…`。等这些条目被统一后,这行注释会消失、表少一行——这不是格式坏了,是冲突清掉了。
88
+
89
+ ## 八、导入与导出按文本类型分开,一次一类
90
+
91
+ Screens(画面)、Others(其它)、Messages(报警)是**三个独立文件**:导出时按类型各出一个文件,导入时一次只吃一个文件(软件按文件里的 `$_Typ` 判断类别)。所以:
92
+
93
+ - 导回一个工程要**分别导三次**(Screens.xls、Others.xls、Messages.xls),只导一个文件,另外两类的屏上文字仍是原文。
94
+ - 导出同理:切换文本类型分别导,或导出后确认目录里三个文件都在。
95
+ - 导出日志(输出窗口)会逐类打印 `导出: 画面 / 导出: Others`,可以据此核对有没有漏类。
96
+