dsh-plc-decode 0.1.4 → 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,504 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """WinCC flexible / SMART 项目文本导出表(.xls/.xlsx)多语言处理器。
4
+
5
+ 项目文本导出表的格式契约(WinCC flexible 导出 → Excel):
6
+ - 第 1 行 $_Languages
7
+ - 第 2 行 语言名,如「4(2) 简体中文(中国大陆)」「9(2) 英语(英国)」;第 1 列=源语言,第 2 列=目标语言
8
+ - 第 3 行 $_Typ(Screens) / $_Attrib(MultiLanguage)
9
+ - 若干 // 注释行
10
+ - 之后每行:第 1 列=源文本(画面上显示什么就以什么为键),第 2 列=目标语言文本
11
+ - 文本可能被 <text>...</text> 包裹;换行是**字面反斜杠 n**(两个字符),不是真换行
12
+ - 目标列出现 $_ignore = 该源文本在工程里存在多份不同译文,导出时被替换;导入时该行被忽略,必须原样保留
13
+
14
+ 三条硬规则(本脚本按它判定):
15
+ 1. 第 1 列是匹配键——改了就对不上,绝不能动
16
+ 2. $_ignore 行照样要给译文(它是多译文冲突被导出挡掉的,不是不用翻)
17
+ 3. 一源一译:同一句源文本全表只能有一个译文,否则导回后工程里又变成多译文、下次导出又变 $_ignore
18
+
19
+ 第四条是校对判据:源文本不含中文(纯编号/代号)而译文比源长 → 判 CODE_EXTRA,
20
+ 即别行的功能词串到这一行了(Q10 top box 这类),应只留编号。
21
+
22
+ 子命令:
23
+ scan --files a.xls b.xls ... --outdir DIR 列出每行的分型(未译/空译/忽略/编号/已有译文),出清单与报告
24
+ apply --files ... --dict 映射.tsv --outdir DIR 按映射写回目标列,出可直接导回的 xls(源是 xls 就出 xls)
25
+ check --src a.xls --out a_译文.xls 自检:行数/键列/忽略行/中文残留/一源一译
26
+
27
+ 映射文件格式(TSV,制表符分隔,UTF-8,可带表头):
28
+ 源文本<TAB>译文
29
+ 换行写 \n(字面两个字符)
30
+ """
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import re
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+
41
+ TEXT_RE = re.compile(r"^<text>(.*)</text>\s*$", re.S)
42
+ CJK_RE = re.compile(r"[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]")
43
+ # 日期/时间格式串(yyyy-M-d H:mm:ss 之类)两列本来就不一样,不能算串位
44
+ DATEFMT_RE = re.compile(r"^[yMdHmsAP:/.\s-]+$")
45
+ IGNORE = "$_ignore"
46
+
47
+
48
+ def looks_datefmt(s):
49
+ s = s.strip()
50
+ return len(s) >= 6 and bool(DATEFMT_RE.match(s))
51
+
52
+ # 项目文本导出表里非正文的头部行数上限(语言行/类型行/注释行)
53
+ HEADER_SCAN_LIMIT = 12
54
+
55
+ LO_KIT_CANDIDATES = [
56
+ os.environ.get("WINCC_LO_KIT", ""),
57
+ r"D:\DSH\Desktop\resources\app.asar.unpacked\dsh\node_modules\@deepseek-ai\libreoffice-kit\lib\cli.js",
58
+ ]
59
+ NODE_CANDIDATES = [
60
+ os.environ.get("WINCC_LO_NODE", ""),
61
+ r"D:\DSH\Desktop\resources\runtime\primary-runtime\dependencies\node\bin\node.exe",
62
+ r"D:\DSH\home\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe",
63
+ ]
64
+
65
+
66
+ # ───────────────────────── 文件格式 ─────────────────────────
67
+
68
+ def _find_lo_kit():
69
+ cli = next((p for p in LO_KIT_CANDIDATES if p and os.path.exists(p)), None)
70
+ node = next((p for p in NODE_CANDIDATES if p and os.path.exists(p)), None)
71
+ return (node, cli) if cli and node else (None, None)
72
+
73
+
74
+ def convert(src, dst):
75
+ """借 LibreOffice Kit 转格式(xls <-> xlsx)。返回 dst。"""
76
+ node, cli = _find_lo_kit()
77
+ if not cli:
78
+ raise SystemExit(
79
+ f"需要 LibreOffice Kit 转换 {os.path.basename(src)},但没找到 cli.js。\n"
80
+ "可设环境变量 WINCC_LO_KIT=<cli.js 路径> / WINCC_LO_NODE=<node 路径>。"
81
+ )
82
+ if os.path.exists(dst):
83
+ os.remove(dst)
84
+ out = subprocess.run([node, cli, "convert", "--input", src, "--output", dst],
85
+ capture_output=True, text=True, encoding="utf-8", errors="replace")
86
+ if out.returncode != 0 or not os.path.exists(dst):
87
+ raise SystemExit(f"转换失败({os.path.basename(src)} -> {os.path.basename(dst)}):\n{out.stdout}\n{out.stderr}")
88
+ return dst
89
+
90
+
91
+ def as_xlsx(path, workdir):
92
+ """拿到一个可被 openpyxl 读的 xlsx 路径。"""
93
+ if path.lower().endswith(".xlsx"):
94
+ return path
95
+ os.makedirs(workdir, exist_ok=True)
96
+ return convert(path, os.path.join(workdir, os.path.splitext(os.path.basename(path))[0] + ".xlsx"))
97
+
98
+
99
+ # ───────────────────────── 解析 ─────────────────────────
100
+
101
+ def parse(path, workdir):
102
+ import openpyxl
103
+ xlsx = as_xlsx(path, workdir)
104
+ wb = openpyxl.load_workbook(xlsx, data_only=True)
105
+ ws = wb.worksheets[0]
106
+
107
+ rows = []
108
+ for r in ws.iter_rows(values_only=True):
109
+ # 整行保留:导入是按列对语言的,多余列必须原样带回去
110
+ rows.append(["" if v is None else str(v) for v in r])
111
+ for row in rows:
112
+ while len(row) < 2:
113
+ row.append("")
114
+
115
+ # 头部:直到出现第一行非注释、非表头的内容
116
+ body_start = 0
117
+ langs = [rows[1][0] if len(rows) > 1 else "", rows[1][1] if len(rows) > 1 else ""]
118
+ typ = ""
119
+ for i, row in enumerate(rows[:HEADER_SCAN_LIMIT]):
120
+ s = row[0].strip()
121
+ if s.startswith("$_Languages"):
122
+ continue
123
+ if s.startswith("$_Typ(") or s.startswith("$_Attrib("):
124
+ typ = typ or s
125
+ continue
126
+ if s.startswith("//"):
127
+ continue
128
+ if re.match(r"^\d+\(\d+\)", s):
129
+ continue
130
+ body_start = i
131
+ break
132
+
133
+ return {"path": path, "rows": rows, "body_start": body_start, "langs": langs, "typ": typ,
134
+ "xlsx": xlsx, "sheet": ws.title}
135
+
136
+
137
+ def core_of(s):
138
+ """去掉 <text> 包裹,返回 (是否包裹, 内容)。"""
139
+ m = TEXT_RE.match(s)
140
+ return (True, m.group(1)) if m else (False, s)
141
+
142
+
143
+ def classify(src, dst):
144
+ """给一行分型。返回 (type, 说明)。"""
145
+ if not src.strip():
146
+ return ("EMPTY_KEY", "源文本为空")
147
+ wrapped, core = core_of(src)
148
+ if dst == IGNORE:
149
+ return ("IGNORE", "多译文冲突,导出时已替换,导入会被忽略")
150
+ if not dst.strip():
151
+ return ("UNTRANSLATED", "目标列空")
152
+ _, core_b = core_of(dst)
153
+ if CJK_RE.search(core_b):
154
+ return ("UNTRANSLATED", "目标列还是中文" if core_b.strip() != core.strip() else "目标列=源文本")
155
+ if not CJK_RE.search(core) and core.strip() == core_b.strip():
156
+ return ("SAME_AS_SRC", "非中文文本,两列相同(编号/符号/日期格式)")
157
+ if not CJK_RE.search(core) and not looks_datefmt(core) and len(core_b.strip()) > len(core.strip()):
158
+ # 源是纯编号/代号,译文却更长 → 别行的功能词串过来了(Q10 top box 这种)
159
+ return ("CODE_EXTRA", "源无中文,译文多出内容(疑串位,应只留编号)")
160
+ return ("HAS_TRANS", "已有译文,需人工核错译/串位")
161
+
162
+
163
+ def scan(p):
164
+ out = []
165
+ for idx in range(p["body_start"], len(p["rows"])):
166
+ row = p["rows"][idx]
167
+ src, dst = row[0], row[1]
168
+ if not src.strip() and not dst.strip():
169
+ continue
170
+ t, note = classify(src, dst)
171
+ out.append({"row": idx + 1, "src": src, "dst": dst, "type": t, "note": note})
172
+ return out
173
+
174
+
175
+ # ───────────────────────── 一源一译 ─────────────────────────
176
+
177
+ def conflicts(items):
178
+ """同一源文本出现多个不同译文 → 导入后工程又变多译文。"""
179
+ seen = {}
180
+ for it in items:
181
+ if it["type"] == "IGNORE":
182
+ continue
183
+ _, core = core_of(it["src"])
184
+ if not core.strip():
185
+ continue
186
+ _, tgt = core_of(it["dst"])
187
+ if not tgt.strip():
188
+ continue
189
+ seen.setdefault(core, {}).setdefault(tgt, []).append(it["row"])
190
+ bad = {}
191
+ for core, m in seen.items():
192
+ if len(m) > 1:
193
+ bad[core] = {k: v for k, v in m.items()}
194
+ return bad
195
+
196
+
197
+ # ───────────────────────── 命令 ─────────────────────────
198
+
199
+ def cmd_scan(args):
200
+ os.makedirs(args.outdir, exist_ok=True)
201
+ work = os.path.join(args.outdir, "_work")
202
+ report = []
203
+ tsv_lines = ["文件\t行号\t分型\t源文本\t现有译文"]
204
+ stat = {}
205
+ for f in args.files:
206
+ p = parse(f, work)
207
+ items = scan(p)
208
+ name = os.path.basename(f)
209
+ c = {}
210
+ for it in items:
211
+ c[it["type"]] = c.get(it["type"], 0) + 1
212
+ tsv_lines.append("\t".join([name, str(it["row"]), it["type"], it["src"].replace("\t", " "), it["dst"].replace("\t", " ")]))
213
+ bad = conflicts(items)
214
+ stat[name] = c
215
+ report.append((name, p, items, bad))
216
+
217
+ list_path = os.path.join(args.outdir, "待译清单.tsv")
218
+ with open(list_path, "w", encoding="utf-8", newline="") as fh:
219
+ fh.write("\n".join(tsv_lines) + "\n")
220
+
221
+ md = ["# 项目文本分型报告", ""]
222
+ total_need = 0
223
+ for name, p, items, bad in report:
224
+ c = {}
225
+ for it in items:
226
+ c[it["type"]] = c.get(it["type"], 0) + 1
227
+ need = c.get("UNTRANSLATED", 0)
228
+ total_need += need
229
+ md.append(f"## {name}")
230
+ md.append("")
231
+ md.append(f"- 语言:{p['langs'][0]} → {p['langs'][1]}({p['typ']})")
232
+ md.append(f"- 正文行 {len(items)}:未译 {need} / 已有译文 {c.get('HAS_TRANS', 0)} / 忽略 {c.get('IGNORE', 0)} / 非中文同形 {c.get('SAME_AS_SRC', 0)} / 编号行挂词 {c.get('CODE_EXTRA', 0)}")
233
+ if bad:
234
+ md.append(f"- ⚠ 一源多译 {len(bad)} 组(导回后工程会产生多译文,必须统一):")
235
+ for core, m in list(bad.items())[:20]:
236
+ md.append(f" - {core!r} → " + ";".join(f"{k!r}(行{','.join(map(str, v))})" for k, v in m.items()))
237
+ md.append("")
238
+ md.insert(1, f"共 {len(args.files)} 个表,未译合计 {total_need} 条。清单:{list_path}")
239
+ md_path = os.path.join(args.outdir, "分型报告.md")
240
+ with open(md_path, "w", encoding="utf-8") as fh:
241
+ fh.write("\n".join(md) + "\n")
242
+
243
+ print("\n".join(md))
244
+ return 0
245
+
246
+
247
+ def load_dict(path):
248
+ """映射:源文本 → 译文。支持 TSV(源\t译文)与 JSON({"源":"译文"})。"""
249
+ m = {}
250
+ if path.lower().endswith(".json"):
251
+ with open(path, encoding="utf-8") as fh:
252
+ raw = json.load(fh)
253
+ for k, v in raw.items():
254
+ m[k] = v
255
+ return m
256
+ with open(path, encoding="utf-8-sig") as fh:
257
+ for line in fh:
258
+ line = line.rstrip("\n").rstrip("\r")
259
+ if not line.strip() or line.startswith("#"):
260
+ continue
261
+ parts = line.split("\t")
262
+ if len(parts) < 2:
263
+ continue
264
+ k, v = parts[0], parts[1]
265
+ if k == "源文本":
266
+ continue
267
+ # 表里的换行是真换行(Excel 单元格内 Alt+Enter),映射文件一行放不下,
268
+ # 所以在映射里写 \n(字面两字符),读进来还原成真换行。
269
+ m[k.replace("\\n", "\n")] = v.replace("\\n", "\n")
270
+ return m
271
+
272
+
273
+ def write_book(rows, path, sheet_name="Sheet1"):
274
+ """写 .xlsx(openpyxl)。"""
275
+ import openpyxl
276
+ wb = openpyxl.Workbook()
277
+ ws = wb.active
278
+ ws.title = sheet_name
279
+ for r in rows:
280
+ ws.append([("" if v is None else v) for v in r])
281
+ wb.save(path)
282
+ return path
283
+
284
+
285
+ def write_xls(src, old_rows, rows, path, sheet_name="Sheet1"):
286
+ """写 Excel 97-2003 .xls。与源表同扩展名、同工作表名,导回软件时才认得出是哪类文本。
287
+
288
+ 首选 Excel COM:拿源表复制一份,只改变化的格子——行数、列数、格式、其他列全原样
289
+ (xlwt 整表重建实测会吞行,394 行写完剩 393,之后整表错位)。
290
+ 没装 Excel 才退到 xlwt,且写完必须回读核对行数。
291
+ """
292
+ errs = []
293
+ for fn in (_write_xls_via_com, _write_xls_via_xlwt):
294
+ try:
295
+ return fn(src, old_rows, rows, path, sheet_name)
296
+ except Exception as exc: # noqa: BLE001 - 记录后试下一条路
297
+ errs.append(f"{fn.__name__}: {exc}")
298
+ raise SystemExit("写 .xls 两条路都不通:\n " + "\n ".join(errs))
299
+
300
+
301
+ def _find_powershell():
302
+ for name in ("powershell.exe", "pwsh.exe", "powershell", "pwsh"):
303
+ p = shutil.which(name)
304
+ if p:
305
+ return p
306
+ return None
307
+
308
+
309
+ _COM_SCRIPT = """
310
+ param([string]$Xls, [string]$Plan)
311
+ $ErrorActionPreference = 'Stop'
312
+ $xl = New-Object -ComObject Excel.Application
313
+ $xl.Visible = $false
314
+ $xl.DisplayAlerts = $false
315
+ try {
316
+ $wb = $xl.Workbooks.Open($Xls)
317
+ $ws = $wb.Worksheets.Item(1)
318
+ foreach ($line in Get-Content -LiteralPath $Plan -Encoding UTF8) {
319
+ if ($line.Trim() -eq '') { continue }
320
+ $i = $line.IndexOf("`t")
321
+ $r = [int]$line.Substring(0, $i)
322
+ $rest = $line.Substring($i + 1)
323
+ $j = $rest.IndexOf("`t")
324
+ $c = [int]$rest.Substring(0, $j)
325
+ $v = $rest.Substring($j + 1).Replace('\\n', "`n")
326
+ $ws.Cells.Item($r, $c).Value2 = $v
327
+ }
328
+ $wb.Save()
329
+ $wb.Close($true)
330
+ } finally {
331
+ $xl.Quit()
332
+ [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($xl)
333
+ }
334
+ """
335
+
336
+
337
+ def _write_xls_via_com(src, old_rows, rows, path, sheet_name="Sheet1"):
338
+ """复制源表 → 用 Excel COM 只改变化的格子 → 保存。行数与格式天然不变。"""
339
+ ps = _find_powershell()
340
+ if not ps:
341
+ raise RuntimeError("找不到 powershell")
342
+ plan = []
343
+ for i in range(max(len(old_rows), len(rows))):
344
+ a = old_rows[i] if i < len(old_rows) else []
345
+ b = rows[i] if i < len(rows) else []
346
+ for c in range(max(len(a), len(b))):
347
+ va = a[c] if c < len(a) else ""
348
+ vb = b[c] if c < len(b) else ""
349
+ if str(va) != str(vb):
350
+ plan.append("%d\t%d\t%s" % (i + 1, c + 1, str(vb).replace("\n", "\\n")))
351
+ if not os.path.exists(path) or os.path.abspath(path) != os.path.abspath(src):
352
+ shutil.copyfile(src, path)
353
+ if not plan:
354
+ return path
355
+ tmpdir = tempfile.mkdtemp(prefix="wincc_xls_")
356
+ plan_path = os.path.join(tmpdir, "plan.tsv")
357
+ with open(plan_path, "w", encoding="utf-8", newline="") as fh:
358
+ fh.write("\n".join(plan) + "\n")
359
+ script = os.path.join(tmpdir, "apply.ps1")
360
+ with open(script, "w", encoding="utf-8-sig") as fh:
361
+ fh.write(_COM_SCRIPT)
362
+ out = subprocess.run([ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script,
363
+ "-Xls", path, "-Plan", plan_path],
364
+ capture_output=True, text=True, encoding="utf-8", errors="replace")
365
+ if out.returncode != 0:
366
+ raise RuntimeError((out.stderr or out.stdout or "COM 写表失败").strip()[:400])
367
+ return path
368
+
369
+
370
+ def _write_xls_via_xlwt(src, old_rows, rows, path, sheet_name="Sheet1"):
371
+ """退化路径:xlwt 整表重建。会让格式与未引用列走样,只在没有 Excel 时用。"""
372
+ import xlwt
373
+ book = xlwt.Workbook(encoding="utf-8")
374
+ # 工作表名必须与软件导出的一致(Screens / Others / Messages)——
375
+ # 写成默认的 Sheet1,导入时软件认不出表类型,会整份跳过。
376
+ sheet = book.add_sheet(sheet_name)
377
+ for r_i, r in enumerate(rows):
378
+ for c_i, v in enumerate(r):
379
+ # 空列也要写:原表有几列就保持几列,导入侧按列对语言
380
+ sheet.write(r_i, c_i, "" if v is None else str(v))
381
+ if os.path.exists(path):
382
+ os.remove(path)
383
+ book.save(path)
384
+ return path
385
+
386
+
387
+ def cmd_apply(args):
388
+ os.makedirs(args.outdir, exist_ok=True)
389
+ work = os.path.join(args.outdir, "_work")
390
+ mapping = load_dict(args.dict)
391
+ if not mapping:
392
+ raise SystemExit(f"映射文件没有内容: {args.dict}")
393
+
394
+ log = []
395
+ applied = 0
396
+ missing = 0
397
+ for f in args.files:
398
+ p = parse(f, work)
399
+ rows = [list(r) for r in p["rows"]]
400
+ for idx in range(p["body_start"], len(rows)):
401
+ src, dst = rows[idx][0], rows[idx][1]
402
+ if not src.strip():
403
+ continue
404
+ wrapped, core = core_of(src)
405
+ # $_ignore 行照样要填:它是「工程里已有多个不同译文」被导出挡掉的,
406
+ # 留着 $_ignore 等于这段文字在表里仍是源语言,导回去屏上也不变。
407
+ if core in mapping:
408
+ new = mapping[core]
409
+ rows[idx][1] = f"<text>{new}</text>" if wrapped else new
410
+ applied += 1
411
+ else:
412
+ # 没给译文的行:源是中文、目标也是中文/空 → 记下来
413
+ _, core_b = core_of(dst)
414
+ if CJK_RE.search(core) and (not core_b.strip() or CJK_RE.search(core_b)):
415
+ missing += 1
416
+ log.append(f"{os.path.basename(f)} 行{idx+1} 未给译文: {core!r}")
417
+
418
+ stem = os.path.splitext(os.path.basename(f))[0]
419
+ if f.lower().endswith(".xls"):
420
+ # 源是 .xls 就出 .xls:WinCC flexible 的导入对话框按扩展名过滤,给 xlsx 会选不到
421
+ out_path = os.path.join(args.outdir, stem + ".xls")
422
+ write_xls(f, p["rows"], rows, out_path, p["sheet"])
423
+ # 写完回读核对:行数与第 1 列必须与源表一致,否则整表会错位
424
+ back = parse(out_path, work)
425
+ if len(back["rows"]) != len(p["rows"]):
426
+ raise SystemExit(f"写出的 {stem}.xls 行数 {len(back['rows'])} ≠ 源表 {len(p['rows'])}")
427
+ for i in range(len(p["rows"])):
428
+ if back["rows"][i][0] != p["rows"][i][0]:
429
+ raise SystemExit(f"写出的 {stem}.xls 行{i+1} 键列与源表不一致")
430
+ else:
431
+ out_path = os.path.join(args.outdir, stem + ".xlsx")
432
+ write_book(rows, out_path, p["sheet"])
433
+ log.append(f"产出: {out_path}")
434
+
435
+ print(f"写回 {applied} 条;未给译文 {missing} 条。")
436
+ if log:
437
+ print("\n".join(log))
438
+ return 0
439
+
440
+
441
+ def cmd_check(args):
442
+ work = os.path.join(args.outdir or tempfile.gettempdir(), "_work")
443
+ src = parse(args.src, work)
444
+ out = parse(args.out, work)
445
+ problems = []
446
+
447
+ if len(src["rows"]) != len(out["rows"]):
448
+ problems.append(f"行数不一致:原 {len(src['rows'])} / 产 {len(out['rows'])}")
449
+ n = min(len(src["rows"]), len(out["rows"]))
450
+ for i in range(n):
451
+ a1 = src["rows"][i][0]
452
+ a2 = out["rows"][i][0]
453
+ if a1 != a2:
454
+ problems.append(f"行{i+1} 键列(第 1 列)被改动:{a1!r} → {a2!r}")
455
+
456
+ items = scan(out)
457
+ for it in items:
458
+ if it["type"] == "UNTRANSLATED":
459
+ problems.append(f"行{it['row']} 未译/{it['note']}:{it['src'][:40]!r}")
460
+ elif it["type"] == "IGNORE":
461
+ # 填了才算译完;还留着 $_ignore 就是目标列仍是源语言
462
+ problems.append(f"行{it['row']} 仍是 $_ignore(未填译文):{it['src'][:40]!r}")
463
+ elif it["type"] == "CODE_EXTRA":
464
+ problems.append(f"行{it['row']} 译文多出内容(疑串位,源无中文):{it['src'][:30]!r} → {it['dst'][:40]!r}")
465
+ bad = conflicts(items)
466
+ for core, m in bad.items():
467
+ problems.append("一源多译:" + repr(core) + " → " + ";".join(map(repr, m.keys())))
468
+
469
+ if problems:
470
+ print(f"✗ 自检发现 {len(problems)} 处问题:")
471
+ for x in problems:
472
+ print(" - " + x)
473
+ return 1
474
+ print(f"✓ 自检通过:{n} 行,键列未动,无未译、无 $_ignore 残留、无串位、无一源多译。")
475
+ return 0
476
+
477
+
478
+ def main(argv=None):
479
+ ap = argparse.ArgumentParser(description="WinCC flexible 项目文本导出表多语言处理")
480
+ sub = ap.add_subparsers(dest="cmd", required=True)
481
+
482
+ s = sub.add_parser("scan", help="分型出清单")
483
+ s.add_argument("--files", nargs="+", required=True)
484
+ s.add_argument("--outdir", required=True)
485
+ s.set_defaults(func=cmd_scan)
486
+
487
+ a = sub.add_parser("apply", help="按映射写回")
488
+ a.add_argument("--files", nargs="+", required=True)
489
+ a.add_argument("--dict", required=True)
490
+ a.add_argument("--outdir", required=True)
491
+ a.set_defaults(func=cmd_apply)
492
+
493
+ c = sub.add_parser("check", help="自检产物")
494
+ c.add_argument("--src", required=True)
495
+ c.add_argument("--out", required=True)
496
+ c.add_argument("--outdir", default="")
497
+ c.set_defaults(func=cmd_check)
498
+
499
+ args = ap.parse_args(argv)
500
+ return args.func(args)
501
+
502
+
503
+ if __name__ == "__main__":
504
+ sys.exit(main())
@@ -0,0 +1,165 @@
1
+ # FStudio 触摸屏多语言翻译 · 工作流
2
+
3
+ 适用:繁易 Flexem FStudio 的触摸屏工程(`.fsprj` + `HMI\Frame` + `Global\TextLibConfigInfo.cfg`)。
4
+ 目标:让画面上的文字随语种切换而变。
5
+
6
+ ## 一句话看清机制(先懂这个,省掉一半返工)
7
+
8
+ FStudio 里画面文字的显示有**两条路**:
9
+
10
+ 1. **元件自带文字**:静态文本元件内部按语种分槽存字(`LanguageSN` 1 中文 / 2 英文 / 3 俄语),
11
+ 切语种时软件自动取对应槽。**存的是字本身,不是引用**。
12
+ 2. **走文本库**:元件勾「使用文本库」并指向某条目,内容由文本库按语种提供。
13
+ 同一个词在多处引用同一条,改一处全局生效。
14
+
15
+ **实测经验**:这类工程的画面文字绝大多数走第 1 条路——库里只有几十条系统文本,元件上是写死的字。
16
+ 「切语种不变」的根因是:**字没进文本库,元件也没勾引用**。
17
+
18
+ ## 正确工作流(三步,别多)
19
+
20
+ ### 第 1 步 · 扫(一次扫全,别一页页看)
21
+ 插件工具 `zzuser-fstudio-i18n`(或 `zzuser-fstudio-scan-texts` 只看清单)。
22
+
23
+ - 扫什么:`HMI\Frame\**\*.wcfg` 里**所有**带语种文字的元件,两条路都要收:
24
+ ① 静态文本:`StaticTextContentInfo` 的 `LanguageSN` + `LabelContent`;
25
+ ② 按钮/开关/指示灯:`Tag/Statuses/TagStatus/TagStatusLanguage` 的 `LanguageId` + `Text`。
26
+ **只扫 ① 会漏掉整批按钮标签**(2026-09-22 踩过:888 → 1899 个元件)。
27
+ - 同时读 `Global\TextLibConfigInfo.cfg` 看库里已有什么。
28
+ - 产出:`元件文本扫描.json` + 中文去重清单。
29
+
30
+ ### 第 2 步 · 译(口径先定,再动手)
31
+ 两条口径,开工前先问清用哪条(2026-09-22 一台设备上被改了三回):
32
+
33
+ - **定版优先(默认)**:词典译文一律覆盖元件里原有的英文,术语统一、机翻味清掉。
34
+ 元件的原英文槽仍写进对照表第 6 列备查。
35
+ - **原件优先**:元件里原本有英文的一律沿用,只补空白项;只有当原件英文属于
36
+ 「词不达意 / 填错内容 / 张冠李戴」三类明显失误时才换成定版,并在表里标出来。
37
+ (判据集合见插件 `fstudio_i18n.py` 的 `FORCE_FIX`。)
38
+
39
+ 其余口径共通:
40
+ - **元件里"中文标签 ↔ 英文标签"成对的要点**——那是工程自带的人工译文,可作译法参考。
41
+ - 现场操作员扫一眼要懂;动词用设备惯用语(Start / Stop / Reset / Jog / Home);
42
+ 厂名音译(拓宇机械 → Tuoyu Machinery);单位与代号按行业写法(mm、°/s、packs/min、S/N、DHCP IP)。
43
+ - **注意**:原工程英文槽常有错填(同一画面几十条都写着 `current position`、`Platen`;
44
+ 甚至有把数字 `8` 当英文的),那是串槽不是译文。
45
+ - 外语比中文长、元件放不下的:按元件在屏上的可用宽度切成多行(真换行);同一句中文全表一个切法(见入口技能「外语撑破控件就分段」)。
46
+
47
+ ### 包装机术语口径(纸品包装线,2026-09-22 定)
48
+ 先判断设备类型(画面名/工序词一看就知道),是包装机就按这套:
49
+
50
+ **产品层级(量词别混)**
51
+ | 实物 | 用词 | 说明 |
52
+ |---|---|---|
53
+ | 软抽小包(盒/包) | **pack** | 生产线成品单位;节拍写 `packs/min`(不要 boxes/min) |
54
+ | 瓦楞外箱(箱) | **carton** | `carton infeed / opening / stop / press cylinder` |
55
+ | 泛称"盒子" | 不用 box | box 是泛称,现场分不清层级;文档场景才用 |
56
+
57
+ **工序词(按行业叫法)**
58
+ | 中文 | 用 | 别用 |
59
+ |---|---|---|
60
+ | 上/下折封 | Top / Bottom folding seal | Top seal |
61
+ | 折角 | Corner tuck | Corner fold |
62
+ | 扯膜 | Film pull | Film tearing |
63
+ | 下膜/上膜 | Bottom / Top film | Lower / Upper film |
64
+ | 送卷、放卷 | Film unwind | Unwind speed |
65
+ | 压纸输送 | Paper press conveyor | Platen conveying |
66
+ | 推纸 | Paper pushing | Push paper |
67
+ | 打码 / 冲孔 | Coding / Perforation | Coder / Punching |
68
+ | 定长 / 追标 | Fixed length / Mark chasing | — |
69
+ | 单光电 / 双光电 | Single / Dual photocell | Single sensor |
70
+ | 测纸长 | Paper length measurement | Measure length |
71
+ | 产品规格 | Pack height / width / length | Tissue height |
72
+
73
+ 仅校准工序与产品词;权限、网络、系统提示等非工艺词保持常规译法,别扩散。
74
+
75
+ ### 食品包装机术语口径(气调保鲜 / 转盘灌装封口,2026-09-28 定)
76
+ 先认设备:画面里有真空室、充气、封切的是**气调保鲜包装机**,有落杯/出杯/灌装/搅拌的是**转盘式灌装封口机**;同一工程的文本库常混着两类。
77
+
78
+ | 中文 | 用 | 中文 | 用 |
79
+ |---|---|---|---|
80
+ | 真空 / 充气 | Vácuo / Injeção de gás | 杯 / 盒 | copo / embalagem |
81
+ | 封切 / 封口 | Selagem e corte / Selagem | 落杯 / 出杯 | Queda do copo / Saída do copo |
82
+ | 收膜 | Tração do filme | 灌装 / 搅拌 | Enchimento / Mistura |
83
+ | 上真空室 | Câmara de vácuo superior | 打码 / 上胶 | Impressão de código / Colagem |
84
+ | 顶盒 | Elevação da embalagem | 上料 / 光电 | Alimentação / Fotocélula |
85
+
86
+ - **开/关成对词用同一词干 + Ligada/Desligada**(机械夹爪的开合用 Aberta/Fechada)。
87
+ - 开=`Atraso Inicial`、关=`Tempo Final`(软件原本就是这套写法);`权限N`=`Permissão N`。
88
+ - 葡语品牌词照抄不译:N2 / CO2 / O2 / OFF / ON / AT / I/O / WiFi / HMI / SD / Pen Drive。
89
+
90
+
91
+ ### 第 3 步 · 出导入表(格式由代码保证)
92
+ `zzuser-fstudio-i18n` 直接产出 `文本库导入表.xlsx`,列名与软件导出逐字符一致:
93
+
94
+ ```
95
+ Guid | Name | Status | 1-Chinese (Simplified, PRC)-2052 | 2-English (United States)-1033
96
+ ```
97
+
98
+ - 每行一个条目:Guid=UUID4、Status=数字 0(默认值节点)、Name 取中文前 50 字。
99
+ - **第三语种按工程实际声明的语种来**(看 `HMI\System\LanguageConfigInfo.cfg` 的 `Lcid`),列名逐字符照抄、别自己拼;只有中英就别加。插件 `LANG_HEADS` 已备好常用列名:`pt` = `3-Portuguese (Brazil)-1046`、`pt-pt` = `3-Portuguese (Portugal)-2070`、`ko` = `3-Korean (Korea)-1042`、`ru` = `3-Russian (Russia)-1049`、`es`/`vi`/`th`/`ar` 同理。**换语种(如去韩语上葡语)时列名里的 LCID 必须与软件里加的语种一致**,否则导进去列对不上。
100
+ - 多语词典写法:python 里加 `ELEMENT_PT`/`LONG_PT`(英表仍是 `ELEMENT_EN`/`LONG_EN`),或 json 写成 `{"en": {…}, "pt": {…}}`;只给英表就只出中英两列。
101
+ - 导入:FStudio 文本库 → 导入 → 选这份表。
102
+ - **状态要够数**:元件有 N 个状态(开关/指示灯/多状态按钮),挂库时取的是**库条目第 N 个状态**的内容;条目只有 1 个状态时,元件状态 1 就是空白(中文也空)。所以「开/关」「上一页/下一页」这类成对元件,条目必须做成多状态条目——导出表里表现为:首行带 Guid+Status 0,后续状态行 **Guid 必须留空**+Status 1/2、Name 与首行相同(重建/生成表时别顺手给后续状态行补 Guid,补了就不是同一条目的第二个状态了,2026-09-26 犯过、被用户当场看出)。单状态的元件用单状态条目即可;拿不准就把条目统一补到 2 状态,第二状态文字取成对词,没有成对词就与原状态同字(工程里原本就是这么写的:定位夹关→定位夹开 是成对,温度控制→温度控制 是同字)。
103
+ - 导入后逐元件勾「使用文本库」指到条目(FStudio 使用手册 4.13.8 标签编辑器,p.570-571)。
104
+
105
+ ### 第 3 步的另一条路 · 已有导出表,只换语种列(retag)
106
+ 工程里文本库已经建好、软件也能导出表时**别重扫工程**,直接把导出的 TextLib 表整列换语种:
107
+
108
+ ```
109
+ python <插件>/assets/fstudio_i18n.py retag <导出的TextLib表.xls> <输出.xlsx> <词典.json> pt 2
110
+ # 或工具/命令:zzuser-fstudio-retag <源表> <词典.json> [输出.xlsx] [语种键] [最少状态数]
111
+ # ↑ 写 2 = 状态不够的条目补到 2 状态
112
+ ```
113
+
114
+ - **只动第 6 列**:Guid、Name、Status、中文列、英文列逐行原样;多状态行的「首行带 Guid+Status 0、后续行 Guid 留空+Status 递增」结构照原样保留(软件导出的就是这个结构,别顺手给后续行补 Guid)。
115
+ - 译文按**该行自己的中文列**取,不是按条目首行——多状态条目第二条常是成对词(成型关→成型开 译成 `Moldagem Desligada` / `Moldagem Ligada`)。
116
+ - **状态数不够就补(最少状态数参数)**:屏上开关/指示灯是双状态元件,库条目只有一个状态时状态 1 会显示空白。补出来的行 Guid 留空、Status 递增、各语种文字与**首行相同**(软件导出的表里就是这么写的,不自己造词)。
117
+ - 交付前机器校验四条:行数一致、前 5 列逐行一致、第三列无空/无汉字/无旧语种残留、抄参考表的译文与参考表逐字一致。
118
+
119
+ ### 要保留原表格式:就地改,走 Excel COM
120
+ 换语种列若要求「格式与原表一致」(用户会这么说),别用 xlwt/openpyxl 另存——**用 Excel COM 打开原件改再保存**,列宽/字体/合并等原样保留。要点与坑:
121
+
122
+ - 改前先备份原件(`*.原件备份.xls`),中途出错直接从备份恢复重做。
123
+ - **`Rows.Insert` 的 CopyOrigin 只管格式来源,不搬内容**——新行是空的,得自己把上一行的 `Range(...).Value` 搬过去(第一版只设了 CopyOrigin,三行全空,被"缺译 3"提示逮住)。插多行时**从下往上插**,行号才不漂。
124
+ - 批量写值要**整块赋值**(`Range(A1:A9).Value = tuple(...)`),逐格 COM 往返慢十倍。
125
+ - 写完 Excel 会把列宽/行高按字符重算(列宽 ±10/256 字符、行高 255→250 twips):列宽用 `Columns(c).ColumnWidth = 原值`(Excel 单位)写回,行高用 `Rows("1:N").RowHeight = 12.75`(=255 twips)写回,写回后与原件一致。
126
+ - 判"格式一致"要用 Excel 的口径(COM 读 `ColumnWidth`/`RowHeight`),别拿 xlrd 的 1/256 原始值硬比——那是文件内部表示,差几十分之一字符是重存的取整差。
127
+ - 控件:`DispatchEx("Excel.Application")` 开独立实例、`Visible=False`、`DisplayAlerts=False`,`finally` 里 `Quit()`;别 `taskkill excel`(用户可能自己开着 Excel)。
128
+ - **词典不一定要留**:改好的表里「中文列 ↔ 目标语种列」本身就是词典,要复用就从成品表反推,不必额外存一份。
129
+
130
+ ## 踩过的坑(照这个避)
131
+
132
+ | 坑 | 现象 | 正解 |
133
+ |---|---|---|
134
+ | 列名不一致 | 导入报「文件格式不正确」 | 表头逐字符对;第 4 列是中英混排长名,不能写成「中文」 |
135
+ | 以为要逐页看 | 大量时间翻画面 | 元件文本一次全扫,上千条几秒出 |
136
+ | 拿元件英文槽当译文 | 译文一堆重复的 current position / Platen | 先查"同一英文被多少中文共用",共用多的是串槽,按中文重译 |
137
+ | 自己造表 | 交出去导不进 | 用插件出表,格式由代码保证 |
138
+ | 一次交多个表 | "怎么又三个表格" | **只交最终能用的那一份**;对照表仅在你需要核对时才给 |
139
+ | 「切韩语/俄语什么都不显示」 | 文本库三语都填了,屏上还是空白 | 按顺序查三处:① **元件语种槽有没有字**(本机实测 494 个元件只有 30 个韩文槽有字,275 个连韩语槽都没有);② **元件有没有勾「使用文本库」**(30 个画面里只有 4 个用到库,光导库画面上什么都没变);③ **该语种的字体**——工程 `HMI\System\LanguageConfigInfo.cfg` 每行一个字体,实测三行的字体类型都是「图形字体」(=按字符串截位图存工程,多语种拿不到字模),且韩语行写的 `Microsoft YaHei` 经 cmap 表实测**不含韩文**(宋体、Arial 也不含;Malgun Gothic 含)。正解:该语种字体类型改**矢量字体**、字体换成含目标语言的(韩文用 Malgun Gothic),再重新下载工程(字模随工程打包)。判据:读字体文件 cmap 表查 U+AC00(韩文)/U+0400(西里尔)是否在覆盖区间内 |
140
+ | 以为元件"挂了库"就会跟着库翻译 | 数出一堆带 TextLibId 的元件,以为导入文本库就能上英文 | 拷贝元件会连着上一条的 `TextLibId` 一起复制。先把每个 TextLibId 解到条目、与元件自身文字比对:对不上的就是拷贝残留(本机实测 494 个元件里 107 个带 TextLibId,98 个能解出条目,其中 **97 个全指向同一条**)。导入库≠上屏,还要逐元件勾「使用文本库」 |
141
+ | 默认带俄语 | "不需要俄语" | 先确认语言范围;不要就不出那列 |
142
+ | 译文写死在脚本里 | 改词要动代码 | 词典单独一个文件(`ELEMENT_EN`/`LONG_EN` 或 json),脚本只调用 |
143
+
144
+ ## 交付约定
145
+
146
+ - 交付目录(你取用处)**只放最终可用件**:`文本库导入表.xlsx`。
147
+ - 过程件(扫描 JSON、对照表、脚本)留在 `解码归档\<工程名>\` 或插件内。
148
+ - 工程原件只读;不写 `Global\TextLibConfigInfo.cfg`、不改 `.wcfg`。
149
+
150
+ ## 词典
151
+
152
+ 插件不带词典也能跑(会列出缺译文的中文条目)。给某台设备配词典就写一个 py 文件:
153
+
154
+ ```python
155
+ ELEMENT_EN = {"拓宇机械": "Tuoyu Machinery", "进料1速度": "Feed 1 speed"}
156
+ LONG_EN = {"说明:...": "Note: ..."} # 长句放这里
157
+ ELEMENT_PT = {"定位夹": "Garra de Posicionamento"} # 第三语种按语种键命名:PT / KO / RU / ES…
158
+ LONG_PT = {}
159
+ ```
160
+
161
+ ```json
162
+ {"en": {"启动1": "Start 1"}, "pt": {"启动1": "Partida 1"}}
163
+ ```
164
+
165
+ 再用 `zzuser-fstudio-i18n` / `zzuser-fstudio-retag` 的 `dict` 参数传入。