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,608 @@
1
+ # -*- coding: utf-8 -*-
2
+ r"""FStudio / Flexem 触摸屏多语言:扫元件文本 → 出中英对照表 → 出可导入文本库表。
3
+
4
+ 四个子命令:
5
+ scan <工程目录或.fsprj> <out.json> 只扫描,出 JSON
6
+ pair <scan.json> <对照表.xlsx> [词典.py] 出「中文|英文|画面|原英文槽」对照表
7
+ make <工程目录或.fsprj> <输出目录> [词典.py] 一步到位:扫描 + 对照表 + 可导入表
8
+ retag <已导出的TextLib表> <输出.xlsx> <词典> [语种键] [最少状态数]
9
+ 换语种列:把导出表的第三语种列
10
+ (如韩语)整列换成目标语种(如巴葡),
11
+ 其余列原样保留 → 可直接导回文本库
12
+ 给「最少状态数」(如 2)时,状态不够的
13
+ 条目按首行补足状态行(Guid 留空、Status 递增、
14
+ 文字与首行同)
15
+
16
+ 可导入表列名与 FStudio 导出的 TextLib 表逐字符一致:
17
+ Guid | Name | Status | 1-Chinese (Simplified, PRC)-2052 | 2-English (United States)-1033
18
+ 给了第三语种词典(.py 的 ELEMENT_KO/ELEMENT_PT/LONG_*,或 .json 的 "ko"/"pt")时,末尾再加一列,
19
+ 列名按语种键取自 LANG_HEADS:
20
+ 3-Korean (Korea)-1042 / 3-Portuguese (Brazil)-1046 / 3-Portuguese (Portugal)-2070 / 3-Russian (Russia)-1049 …
21
+ """
22
+ import collections
23
+ import json
24
+ import os
25
+ import re
26
+ import sys
27
+ import uuid
28
+ import xml.etree.ElementTree as ET
29
+
30
+ NS = "{http://schemas.flexem.com/fs/model/1}"
31
+ CJK = re.compile(r"[\u4e00-\u9fff]")
32
+ HEAD_ZH = "1-Chinese (Simplified, PRC)-2052"
33
+ HEAD_EN = "2-English (United States)-1033"
34
+ HEAD_KO = "3-Korean (Korea)-1042"
35
+
36
+ # 第三语种:词典键 → 导入表列名(列名必须与 FStudio 导出的 TextLib 表逐字符一致)
37
+ LANG_HEADS = {
38
+ "ko": "3-Korean (Korea)-1042",
39
+ "pt": "3-Portuguese (Brazil)-1046",
40
+ "pt-pt": "3-Portuguese (Portugal)-2070",
41
+ "ru": "3-Russian (Russia)-1049",
42
+ "es": "3-Spanish (Spain)-3082",
43
+ "vi": "3-Vietnamese (Vietnam)-1066",
44
+ "th": "3-Thai (Thailand)-1054",
45
+ "ar": "3-Arabic (Saudi Arabia)-1025",
46
+ }
47
+ # 词典里找第三语种的顺序(都给了就按这个优先级取第一个)
48
+ LANG_ORDER = ["pt", "pt-pt", "ko", "ru", "es", "vi", "th", "ar"]
49
+ _LANG_ATTRS = {"pt-pt": "PT_PT", "pt": "PT", "ko": "KO", "ru": "RU", "es": "ES",
50
+ "vi": "VI", "th": "TH", "ar": "AR"}
51
+
52
+
53
+ # ────────────────────────── 工程解析 ──────────────────────────
54
+
55
+ def project_dir(p):
56
+ """传入 .fsprj 或工程目录,返回工程目录。"""
57
+ if p.lower().endswith(".fsprj"):
58
+ return os.path.dirname(os.path.abspath(p))
59
+ return os.path.abspath(p)
60
+
61
+
62
+ def scan_elements(proj):
63
+ """静态文本元件:每个元件按语种槽取 LabelContent(1 中文 / 2 英文 / 3 俄语)。"""
64
+ frame = os.path.join(project_dir(proj), "HMI", "Frame")
65
+ out = []
66
+ for dirpath, _dirs, files in os.walk(frame):
67
+ for fn in files:
68
+ if not fn.lower().endswith(".wcfg"):
69
+ continue
70
+ try:
71
+ root = ET.parse(os.path.join(dirpath, fn)).getroot()
72
+ except Exception:
73
+ continue
74
+ win = (root.findtext(NS + "Name") or "").strip()
75
+ for g in root.iter(NS + "GraphicsBase"):
76
+ etype = g.get("{http://www.w3.org/2001/XMLSchema-instance}type") or ""
77
+ labs = collect_labels(g)
78
+ if not labs:
79
+ continue
80
+ out.append({"win": win, "file": fn, "type": etype,
81
+ "gid": g.findtext(NS + "UniqueId") or "",
82
+ "tlib": g.findtext(NS + "NormalInfo/" + NS + "TextLibId") or "",
83
+ "labels": labs})
84
+ return out
85
+
86
+
87
+ def collect_labels(g):
88
+ """一个元件里所有按语种分槽的文字。
89
+
90
+ 三条路都要收(2026-09-22 修两轮:
91
+ ① 原先只收静态文本,漏掉按钮/开关的一整套标签;
92
+ ② 第二版只从 TagStatus 往下找,漏掉直接挂在 Statuses/Items 下的那种):
93
+ ① 静态文本:StaticTextContentInfo 里的 LanguageSN + LabelContent
94
+ ② 按钮/开关/指示灯:凡 TagStatusLanguage(不管父层是 TagStatus 还是 Statuses/Items)
95
+ ③ 少数元件:TitleSettingLanguage
96
+ 返回 [{lang, text, status}],lang 为语种号(1 中文 / 2 英文 / 3 俄语)。
97
+ """
98
+ labs = []
99
+ for ci in g.iter(NS + "StaticTextContentInfo"):
100
+ sn = ci.findtext(NS + "LanguageSN")
101
+ tx = (ci.findtext(NS + "LabelContent") or "").strip()
102
+ if sn is not None:
103
+ labs.append({"lang": sn, "text": tx, "status": ""})
104
+ # 不依赖父层:凡是 TagStatusLanguage 都收(父层可能是 TagStatus,也可能直接是 Statuses/Items)
105
+ for tl in g.iter(NS + "TagStatusLanguage"):
106
+ lid = tl.findtext(NS + "LanguageId")
107
+ tx = (tl.findtext(NS + "Text") or "").strip()
108
+ if lid is not None:
109
+ labs.append({"lang": lid, "text": tx, "status": ""})
110
+ # 少数元件把标签放在 TitleSettingContentInfos 下
111
+ for tl in g.iter(NS + "TitleSettingLanguage"):
112
+ lid = tl.findtext(NS + "LanguageId")
113
+ tx = (tl.findtext(NS + "Text") or "").strip()
114
+ if lid is not None:
115
+ labs.append({"lang": lid, "text": tx, "status": ""})
116
+ return labs
117
+
118
+
119
+ def scan_library(proj):
120
+ """文本库现有条目(Global\\TextLibConfigInfo.cfg)。"""
121
+ cfg = os.path.join(project_dir(proj), "Global", "TextLibConfigInfo.cfg")
122
+ if not os.path.exists(cfg):
123
+ return []
124
+ root = ET.parse(cfg).getroot()
125
+ items = []
126
+ for it in root.findall(NS + "TextItemInfos/" + NS + "TextItemInfo"):
127
+ langs = collections.defaultdict(set)
128
+ for st in it.findall(NS + "TextStatusInfos/" + NS + "TextStatusInfo"):
129
+ for lt in st.findall(NS + "LanguageTextInfos/" + NS + "LanguageTextInfo"):
130
+ sn = lt.findtext(NS + "LanguageSN")
131
+ tx = (lt.findtext(NS + "LanguageText") or "").strip()
132
+ if tx and sn:
133
+ langs[sn].add(tx)
134
+ items.append({"name": it.findtext(NS + "Name"),
135
+ "langs": {k: sorted(v) for k, v in langs.items()}})
136
+ return items
137
+
138
+
139
+ def texts_of(e, lang):
140
+ """某个元件里指定语种的全部文字(去重,保序)。"""
141
+ out = []
142
+ for l in e.get("labels", []):
143
+ if str(l.get("lang")) == str(lang):
144
+ t = (l.get("text") or "").strip()
145
+ if t and t not in out:
146
+ out.append(t)
147
+ return out
148
+
149
+
150
+ def build_pairs(elems):
151
+ """元件里中文标签 ↔ 英文标签 的对应(原工程自带的译文,作为译法参考)。"""
152
+ pair = collections.defaultdict(collections.Counter)
153
+ for e in elems:
154
+ zs, es = texts_of(e, 1), texts_of(e, 2)
155
+ for zh, en in zip(zs, es):
156
+ if zh and en:
157
+ pair[zh][en] += 1
158
+ return {k: v.most_common(1)[0][0] for k, v in pair.items()}
159
+
160
+
161
+ # ────────────────────────── 词典 ──────────────────────────
162
+
163
+ def load_dict(path, lang=None):
164
+ """词典,两种写法:
165
+ · 旧(单语):{中文: 英文} 的 json,或含 ELEMENT_EN/LONG_EN 的 python
166
+ · 多语:json {"en": {...}, "pt": {...}},或 python 里再加 ELEMENT_PT/LONG_PT
167
+ 返回 (英表, lookup, rename, 第三语种表, 第三语种键);rename 是项目级「中文改写」表。
168
+ lang 显式给了就用它(平表也当该语种);没给时按 LANG_ORDER 在词典里找第一个第三语种。"""
169
+ if not path or not os.path.exists(path):
170
+ return {}, None, {}, {}, ""
171
+ if path.endswith(".json"):
172
+ data = json.load(open(path, encoding="utf-8"))
173
+ if isinstance(data, dict) and any(isinstance(v, dict) for v in data.values()):
174
+ en = data.get("en") or {}
175
+ key = lang if lang in data else next((k for k in LANG_ORDER if k in data), "")
176
+ return en, None, {}, (data.get(key) or {}) if key else {}, key
177
+ # 平表:给了 lang 就当该语种,否则当英文表
178
+ if lang:
179
+ return {}, None, {}, data, lang
180
+ return data, None, {}, {}, ""
181
+ import importlib.util
182
+ spec = importlib.util.spec_from_file_location("i18n_dict", path)
183
+ mod = importlib.util.module_from_spec(spec)
184
+ spec.loader.exec_module(mod)
185
+ table = {}
186
+ for nm in ("ELEMENT_EN", "LONG_EN"):
187
+ table.update(getattr(mod, nm, None) or {})
188
+ key = lang if lang else next(
189
+ (k for k in LANG_ORDER if getattr(mod, "ELEMENT_" + _LANG_ATTRS[k], None)), "")
190
+ third = {}
191
+ if key:
192
+ for nm in ("ELEMENT_" + _LANG_ATTRS[key], "LONG_" + _LANG_ATTRS[key]):
193
+ third.update(getattr(mod, nm, None) or {})
194
+ return table, getattr(mod, "lookup", None), (getattr(mod, "RENAME", None) or {}), third, key
195
+
196
+
197
+ def apply_rename(text, rename):
198
+ """按项目词典里的 RENAME 把中文旧写法换成规范写法(如 常按→长按)。"""
199
+ if not rename or not text:
200
+ return text
201
+ for old, new in rename.items():
202
+ if old in text:
203
+ text = text.replace(old, new)
204
+ return text
205
+
206
+
207
+ def translate(text, table, lookup):
208
+ if text in table:
209
+ return table[text]
210
+ key = (text or "").replace("\r\n", "\n")
211
+ if key in table:
212
+ return table[key]
213
+ if lookup:
214
+ return lookup(text) or ""
215
+ return ""
216
+
217
+
218
+ # ────────────────────────── 输出 ──────────────────────────
219
+
220
+ # 对照表里第三语种列的中文标题
221
+ LANG_LABELS = {"ko": "韩文", "pt": "葡文(巴)", "pt-pt": "葡文(葡)", "ru": "俄文",
222
+ "es": "西文", "vi": "越文", "th": "泰文", "ar": "阿文"}
223
+
224
+
225
+ def write_pair_table(rows, out_xlsx, ko=None, lang="ko"):
226
+ """对照表:序号|中文|英文|(第三语种)|出现次数|所在画面|元件里原来的英文槽|英文来源。
227
+ ko({中文: 第三语种})非空时多一列,缺的标黄;lang 决定列标题。"""
228
+ from openpyxl import Workbook
229
+ from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
230
+ from openpyxl.utils import get_column_letter
231
+ has_ko = bool(ko)
232
+ head = ["序号", "中文", "英文"] + ([LANG_LABELS.get(lang, lang)] if has_ko else []) + \
233
+ ["出现次数", "所在画面", "元件里原来的英文槽", "英文来源"]
234
+ ncol = len(head)
235
+ i_en, i_ko, i_slot, i_src = 3, (4 if has_ko else 0), ncol - 2, ncol
236
+ wb = Workbook()
237
+ ws = wb.active
238
+ ws.title = "对照表"
239
+ thin = Side(style="thin", color="BFBFBF")
240
+ border = Border(left=thin, right=thin, top=thin, bottom=thin)
241
+ hf = PatternFill("solid", fgColor="1F4E79")
242
+ hfont = Font(color="FFFFFF", bold=True)
243
+ yellow = PatternFill("solid", fgColor="FFF2CC")
244
+ ws.append(head)
245
+ for c in range(1, ncol + 1):
246
+ cell = ws.cell(row=1, column=c)
247
+ cell.fill, cell.font, cell.border = hf, hfont, border
248
+ cell.alignment = Alignment(horizontal="center", vertical="center")
249
+ wrap = {2, 3, i_slot, i_src} | ({i_ko} if has_ko else set())
250
+ for idx, (zh, en, n, wins, slot, src_note) in enumerate(rows, 1):
251
+ vals = [idx, zh, en] + ([ko.get(zh, "")] if has_ko else []) + [n, wins, slot, src_note]
252
+ ws.append(vals)
253
+ r = ws.max_row
254
+ for c in range(1, ncol + 1):
255
+ ws.cell(row=r, column=c).border = border
256
+ ws.cell(row=r, column=c).alignment = Alignment(vertical="top", wrap_text=(c in wrap))
257
+ if not en:
258
+ ws.cell(row=r, column=i_en).fill = yellow
259
+ ws.cell(row=r, column=i_src).fill = yellow
260
+ if has_ko and not ko.get(zh, ""):
261
+ ws.cell(row=r, column=i_ko).fill = yellow
262
+ widths = [6, 34, 44] + ([36] if has_ko else []) + [10, 34, 34, 18]
263
+ for i, w in enumerate(widths, 1):
264
+ ws.column_dimensions[get_column_letter(i)].width = w
265
+ ws.freeze_panes = "A2"
266
+ ws.auto_filter.ref = "A1:%s%d" % (get_column_letter(ncol), ws.max_row)
267
+ wb.save(out_xlsx)
268
+
269
+
270
+ def write_import_table(rows, out_xlsx, sheet="TextLib", ko=None, lang="ko"):
271
+ """rows: (中文, 采用的英文, 次数, 画面, 原英文槽, 是否新补/替换);ko: {中文: 第三语种}
272
+ FStudio 可导入格式:每行一个条目、Guid 唯一、Status=0。ko 非空时带第三语种列(列名按 lang)。"""
273
+ from openpyxl import Workbook
274
+ from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
275
+ from openpyxl.utils import get_column_letter
276
+ has_ko = bool(ko)
277
+ head = ["Guid", "Name", "Status", HEAD_ZH, HEAD_EN] + \
278
+ ([LANG_HEADS.get(lang, HEAD_KO)] if has_ko else [])
279
+ ncol = len(head)
280
+ wb = Workbook()
281
+ ws = wb.active
282
+ ws.title = sheet
283
+ thin = Side(style="thin", color="BFBFBF")
284
+ border = Border(left=thin, right=thin, top=thin, bottom=thin)
285
+ hf = PatternFill("solid", fgColor="1F4E79")
286
+ hfont = Font(color="FFFFFF", bold=True)
287
+ yellow = PatternFill("solid", fgColor="FFF2CC")
288
+ ws.append(head)
289
+ for c in range(1, ncol + 1):
290
+ cell = ws.cell(row=1, column=c)
291
+ cell.fill, cell.font, cell.border = hf, hfont, border
292
+ cell.alignment = Alignment(horizontal="center", vertical="center")
293
+ for row in rows:
294
+ zh, en = row[0], row[1]
295
+ name = re.sub(r"\s+", " ", zh.replace("\r\n", " ").replace("\n", " ")).strip()[:50] or "text"
296
+ vals = [str(uuid.uuid4()), name, 0, zh, en] + ([ko.get(zh, "")] if has_ko else [])
297
+ ws.append(vals)
298
+ r = ws.max_row
299
+ for c in range(1, ncol + 1):
300
+ ws.cell(row=r, column=c).border = border
301
+ ws.cell(row=r, column=c).alignment = Alignment(vertical="top", wrap_text=(c in (2, 4, 5, 6)))
302
+ ws.cell(row=r, column=3).number_format = "0"
303
+ if not en:
304
+ ws.cell(row=r, column=5).fill = yellow
305
+ if has_ko and not ko.get(zh, ""):
306
+ ws.cell(row=r, column=6).fill = yellow
307
+ widths = [38, 40, 8, 46, 52] + ([40] if has_ko else [])
308
+ for i, w in enumerate(widths, 1):
309
+ ws.column_dimensions[get_column_letter(i)].width = w
310
+ ws.freeze_panes = "A2"
311
+ wb.save(out_xlsx)
312
+
313
+
314
+ # ────────────────────────── 主流程 ──────────────────────────
315
+
316
+ def aggregate(elems, rename=None):
317
+ """把元件文字聚成 {中文: {n, wins, slot}};slot 记元件里原有的英文(若有)。
318
+ rename:项目级中文改写(旧写法→规范写法),聚合前先套用。"""
319
+ agg = collections.OrderedDict()
320
+ for e in elems:
321
+ zs = [apply_rename(t, rename) for t in texts_of(e, 1) if CJK.search(t)]
322
+ es = texts_of(e, 2)
323
+ if not zs:
324
+ continue
325
+ # 中文与英文在元件里按顺序成组:第 i 个中文对应第 i 个英文
326
+ for i, zh in enumerate(zs):
327
+ a = agg.setdefault(zh, {"n": 0, "wins": [], "slot": ""})
328
+ a["n"] += 1
329
+ if e["win"] not in a["wins"]:
330
+ a["wins"].append(e["win"])
331
+ if not a["slot"] and i < len(es):
332
+ a["slot"] = es[i]
333
+ return agg
334
+
335
+
336
+ # 判定为“原件英文有明显失误、必须换成定版”的条目(其余一律沿用原件英文)
337
+ FORCE_FIX = {
338
+ "标记取料位置", "标记放料位置", "标记取料过顶位置", "标记放料过顶位置",
339
+ "标记放料吹气位置", "标记回零位置", "标记待料位置", # 原为 back to/zero,词不达意
340
+ "点进", "点退", "点动", # 原为 point/feed
341
+ "回零", "正向回零", "反向回零", # 原为 back to/zero / point/feed
342
+ "返回", "上一页", "下一页", # 大小写/缩写统一
343
+ "吸料真空", "吸料真空检测", # 原件填的是数字 8
344
+ "拨号", # Dail 拼写错
345
+ "保存绝对值", "非保存绝对值", # 原为 point/feed
346
+ "操作界面", # operation/interface 不是行业说法
347
+ "加热开", "加热关", # heat on/off → Heater on/off
348
+ }
349
+
350
+
351
+ def merge_rows(agg, table, lookup):
352
+ """定译口径(2026-09-22 用户最终定:全用定版译文)。
353
+ · 有词典译文 → 一律用定版(覆盖元件里原有的英文);
354
+ · 词典没有 → 退回元件原有英文;再没有 → 空(标黄)。
355
+ 返回 (zh, 采用的英文, 次数, 画面, 原英文槽, 来源标记)。"""
356
+ rows = []
357
+ for zh, a in sorted(agg.items(), key=lambda kv: (-kv[1]["n"], kv[0])):
358
+ # 不做任何"同义/错别字合并":元件上怎么写就怎么出条目,
359
+ # 否则元件在文本库里会找不到对应条目(2026-09-22 踩过:档箱/挡箱 两条只出一条)
360
+ slot = (a.get("slot") or "").strip()
361
+ mine = translate(zh, table, lookup)
362
+ # 防呆:译文里若混着汉字,等于没译(如“班组计数 on”),一律作废,让缺译清单暴露出来
363
+ if mine and CJK.search(mine):
364
+ mine = ""
365
+ # 兜底:原件英文槽若本身混着汉字,同样不算译文
366
+ if slot and CJK.search(slot):
367
+ slot = ""
368
+ if mine:
369
+ en = mine
370
+ src_note = "定版(覆盖原件英文)" if slot else "定版(原件无英文)"
371
+ else:
372
+ en = slot
373
+ src_note = "退用原件英文" if slot else "缺译文"
374
+ rows.append((zh, en, a["n"], "、".join(a["wins"]), slot, src_note))
375
+ return rows
376
+
377
+
378
+ def collect(proj, table, lookup, rename=None):
379
+ elems = scan_elements(proj)
380
+ return elems, merge_rows(aggregate(elems, rename), table, lookup)
381
+
382
+
383
+ def cmd_scan(args):
384
+ proj, out = args[0], args[1]
385
+ data = {"elems": scan_elements(proj), "lib": scan_library(proj)}
386
+ data["pairs"] = build_pairs(data["elems"])
387
+ json.dump(data, open(out, "w", encoding="utf-8"), ensure_ascii=False)
388
+ print("元件=%d 文本库条目=%d -> %s" % (len(data["elems"]), len(data["lib"]), out))
389
+
390
+
391
+ def cmd_pair(args):
392
+ scan_json, out = args[0], args[1]
393
+ d = json.load(open(scan_json, encoding="utf-8"))
394
+ table, lookup, rename, third, lang = load_dict(args[2] if len(args) > 2 else None)
395
+ elems = d["elems"]
396
+ agg = aggregate(elems, rename)
397
+ rows = merge_rows(agg, table, lookup)
398
+ write_pair_table(rows, out, third, lang or "ko")
399
+ noen = [r for r in rows if not r[1]]
400
+ nothird = [r for r in rows if third and not third.get(r[0])]
401
+ print("对照表=%d 条(无英文 %d,无第三语种 %d)-> %s" % (len(rows), len(noen), len(nothird), out))
402
+ for r in noen[:20]:
403
+ print(" 缺英文:", r[0][:40])
404
+
405
+
406
+ def cmd_make(args):
407
+ proj, outdir = args[0], args[1]
408
+ dpath = args[2] if len(args) > 2 else None
409
+ os.makedirs(outdir, exist_ok=True)
410
+ table, lookup, rename, third, lang = load_dict(dpath)
411
+ elems, rows = collect(proj, table, lookup, rename)
412
+ pair_x = os.path.join(outdir, "元件多语言对照表.xlsx")
413
+ imp_x = os.path.join(outdir, "文本库导入表.xlsx")
414
+ write_pair_table(rows, pair_x, third, lang or "ko")
415
+ write_import_table(rows, imp_x, ko=third, lang=lang or "ko")
416
+ noen = [r for r in rows if not r[1]]
417
+ nothird = [r for r in rows if third and not third.get(r[0])]
418
+ print("元件=%d;对照表=%d 条;无英文=%d;无第三语种=%d;语种=%s" % (
419
+ len(elems), len(rows), len(noen), len(nothird),
420
+ "中/英/%s" % (LANG_LABELS.get(lang, lang)) if third else "中/英"))
421
+ print("对照表 ->", pair_x)
422
+ print("导入表 ->", imp_x)
423
+ if noen:
424
+ print("--- 这些还没英文,补进词典后重跑 ---")
425
+ for r in noen[:30]:
426
+ print(" ", r[0][:50])
427
+ if nothird:
428
+ print("--- 这些还没第三语种译文,补进词典后重跑 ---")
429
+ for r in nothird[:30]:
430
+ print(" ", r[0][:50])
431
+
432
+
433
+ # ────────────────────────── 换语种列(已导出的 TextLib 表)──────────────────────────
434
+
435
+ def read_lib_table(path):
436
+ """读 FStudio 导出的 TextLib 表(.xls 走 xlrd,.xlsx 走 openpyxl)。
437
+ 返回 (表名, 表头, 行列表);数字单元格转整数,文本**原样保留**(软件导出的 Name 列常带首尾空格,
438
+ strip 掉会让前几列与源表对不上、失去「原样」保证)。
439
+ 注:表头与中文列的空值判断用 .strip() 只看空不空,落盘的值不动。"""
440
+ if path.lower().endswith(".xls"):
441
+ import xlrd
442
+ b = xlrd.open_workbook(path)
443
+ sh = b.sheet_by_index(0)
444
+ head = [str(sh.cell_value(0, c)).strip() for c in range(sh.ncols)]
445
+ rows = []
446
+ for r in range(1, sh.nrows):
447
+ vals = []
448
+ for c in range(sh.ncols):
449
+ v = sh.cell_value(r, c)
450
+ if isinstance(v, float) and v == int(v):
451
+ v = int(v)
452
+ vals.append("" if v is None else (v if isinstance(v, int) else str(v)))
453
+ if any(str(x).strip() for x in vals):
454
+ rows.append(vals)
455
+ return sh.name, head, rows
456
+ from openpyxl import load_workbook
457
+ wb = load_workbook(path, data_only=True)
458
+ ws = wb[wb.sheetnames[0]]
459
+ head = [(str(c.value).strip() if c.value is not None else "") for c in ws[1]]
460
+ rows = []
461
+ for r in ws.iter_rows(min_row=2, values_only=True):
462
+ vals = []
463
+ for v in r:
464
+ if v is None:
465
+ vals.append("")
466
+ elif isinstance(v, float) and v == int(v):
467
+ vals.append(int(v))
468
+ elif isinstance(v, int):
469
+ vals.append(v)
470
+ else:
471
+ vals.append(str(v))
472
+ if any(str(x).strip() for x in vals):
473
+ rows.append(vals)
474
+ return ws.title, head, rows
475
+
476
+
477
+ def write_lib_table(sheet, head, rows, out_xlsx):
478
+ """按 FStudio 导出的样子写回:表头原样、Status 走数字、其余列文本。
479
+ 输出按扩展名走:.xls(需 xlwt)或 .xlsx(openpyxl)。"""
480
+ if out_xlsx.lower().endswith(".xls"):
481
+ import xlwt
482
+ wb = xlwt.Workbook(encoding="utf-8")
483
+ ws = wb.add_sheet(sheet or "TextLib")
484
+ for c, h in enumerate(head):
485
+ ws.write(0, c, h)
486
+ for r, row in enumerate(rows, 1):
487
+ for c, v in enumerate(row):
488
+ ws.write(r, c, v if isinstance(v, int) else ("" if v is None else str(v)))
489
+ wb.save(out_xlsx)
490
+ return
491
+ from openpyxl import Workbook
492
+ from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
493
+ from openpyxl.utils import get_column_letter
494
+ wb = Workbook()
495
+ ws = wb.active
496
+ ws.title = sheet or "TextLib"
497
+ thin = Side(style="thin", color="BFBFBF")
498
+ border = Border(left=thin, right=thin, top=thin, bottom=thin)
499
+ hf = PatternFill("solid", fgColor="1F4E79")
500
+ hfont = Font(color="FFFFFF", bold=True)
501
+ ws.append(head)
502
+ for c in range(1, len(head) + 1):
503
+ cell = ws.cell(row=1, column=c)
504
+ cell.fill, cell.font, cell.border = hf, hfont, border
505
+ cell.alignment = Alignment(horizontal="center", vertical="center")
506
+ for row in rows:
507
+ ws.append(row)
508
+ r = ws.max_row
509
+ for c in range(1, len(head) + 1):
510
+ cell = ws.cell(row=r, column=c)
511
+ cell.border = border
512
+ cell.alignment = Alignment(vertical="top", wrap_text=(c != 3))
513
+ ws.cell(row=r, column=3).number_format = "0"
514
+ widths = [38, 40, 8, 46, 52, 46]
515
+ for i, w in enumerate(widths[:len(head)], 1):
516
+ ws.column_dimensions[get_column_letter(i)].width = w
517
+ ws.freeze_panes = "A2"
518
+ wb.save(out_xlsx)
519
+
520
+
521
+ def expand_status(rows, min_status):
522
+ """把状态数不足 min_status 的条目补足:新增行 Guid 留空、Status 递增、各语种文字与**首行相同**。
523
+
524
+ 判组按 FStudio 导出结构:首行带 Guid、后续状态行 Guid 为空。
525
+ (工程里成对词的条目本来就是两个状态各写各的字;单状态条目补的第二个状态就与首行同字,
526
+ 软件导出的表里也是这么写的,不自己造词。)
527
+ 返回 (新行列表, 补了几行)。"""
528
+ if not min_status or min_status < 2:
529
+ return rows, 0
530
+ out, added, i = [], 0, 0
531
+ while i < len(rows):
532
+ head = rows[i]
533
+ grp = [head]
534
+ j = i + 1
535
+ while j < len(rows) and not str(rows[j][0]).strip():
536
+ grp.append(rows[j])
537
+ j += 1
538
+ out.extend(grp)
539
+ for k in range(max(0, min_status - len(grp))):
540
+ new = list(head)
541
+ new[0] = ""
542
+ new[2] = len(grp) + k
543
+ out.append(new)
544
+ added += 1
545
+ i = j
546
+ return out, added
547
+
548
+
549
+ def cmd_retag(args):
550
+ """retag <已导出TextLib表> <输出.xlsx> <词典> [语种键] [最少状态数]
551
+ 第三语种列整列换成词典给的语种,行序/Guid/Status/中英文列原样 → 可直接导回文本库。
552
+ 给了「最少状态数」(如 2)时,状态不够的条目按其首行补足状态行。"""
553
+ src, out = args[0], args[1]
554
+ dpath = args[2] if len(args) > 2 else None
555
+ lang_want = args[3] if len(args) > 3 else None
556
+ min_status = int(args[4]) if len(args) > 4 and str(args[4]).strip().isdigit() else 0
557
+ table, lookup, _rename, third, lang = load_dict(dpath, lang_want)
558
+ lang = lang_want or lang
559
+ if not third:
560
+ raise SystemExit("词典里没找到第三语种译文(.json 写 \"pt\"/\"ko\",.py 写 ELEMENT_PT/ELEMENT_KO);"
561
+ "语种键:%s" % (lang_want or "(未指定)"))
562
+ head_new = LANG_HEADS.get(lang)
563
+ if not head_new:
564
+ raise SystemExit("未知语种键 %s(可选: %s)" % (lang, "、".join(LANG_HEADS)))
565
+ sheet, head, rows = read_lib_table(src)
566
+ rows, added = expand_status(rows, min_status)
567
+ # 列位置:0 Guid / 1 Name / 2 Status / 3 中文 / 4 第二语种 / 5 第三语种
568
+ out_head = list(head[:5]) + [head_new]
569
+ out_rows, miss = [], []
570
+ for r in rows:
571
+ zh = str(r[3]).strip() if len(r) > 3 else ""
572
+ txt = third.get(zh, "")
573
+ if not txt and lookup:
574
+ txt = lookup(zh) or ""
575
+ if not txt:
576
+ miss.append(zh)
577
+ out_rows.append(list(r[:5]) + [txt])
578
+ if miss:
579
+ for t in list(dict.fromkeys(miss))[:30]:
580
+ print(" 缺:", repr(t[:40]))
581
+ raise SystemExit("有 %d 行没译文,未输出(先补词典)" % len(miss))
582
+ write_lib_table(sheet, out_head, out_rows, out)
583
+ print("源表: %s(%d 行 × %d 列,表名 %s)" % (src, len(rows) - added, len(head), sheet))
584
+ print("目标语种: %s -> 列名 %s" % (lang, head_new))
585
+ if min_status:
586
+ print("补状态行: 最少 %d 个状态,本次补了 %d 行" % (min_status, added))
587
+ print("输出行数 %d;缺译 0" % len(out_rows))
588
+ print("输出 ->", out)
589
+
590
+
591
+ def main():
592
+ if len(sys.argv) < 2:
593
+ raise SystemExit(__doc__)
594
+ cmd, args = sys.argv[1], sys.argv[2:]
595
+ if cmd == "scan":
596
+ cmd_scan(args)
597
+ elif cmd == "pair":
598
+ cmd_pair(args)
599
+ elif cmd == "make":
600
+ cmd_make(args)
601
+ elif cmd == "retag":
602
+ cmd_retag(args)
603
+ else:
604
+ raise SystemExit("未知子命令: %s" % cmd)
605
+
606
+
607
+ if __name__ == "__main__":
608
+ main()
@@ -0,0 +1,53 @@
1
+ # hmi-decode-workflow —— 触摸屏工程读取工作流
2
+
3
+ 触摸屏(HMI)工程走**本入口**,与 PLC 分开:PLC 工程(`.project` / `.dvp` / `.smart` / `.smartlib`)用 `plc-decode`,屏工程用 `hmi-decode`。
4
+
5
+ ## 一、先认类型(别拿错工具)
6
+
7
+ | 文件 | 是什么 | 用哪个 |
8
+ |---|---|---|
9
+ | `.hmismart` | 西门子 WinCC flexible SMART 触摸屏工程(Smart Line 屏) | 工具 `hmi-decode`(解码)/`hmi-diff-project`(新旧对比) |
10
+ | `.fsprj` 或含 `HMI\Frame` 的目录 | 繁易 FStudio / Flexem 触摸屏工程 | 工具 `hmi-decode`(扫文本)/`fstudio-scan-texts`/`fstudio-i18n`(多语言出表) |
11
+ | `<工程名>.设备_1.srt`(同目录) | 项目文本侧的序列化存储 | 主文件被锁时的旁路,见第三节 |
12
+ | `Screens.xls` / `Others.xls` / `Messages.xls` | WinCC flexible「项目文本」导出成的 Excel 三件套 | 工具 `wincc-text-i18n`(分型/回写/自检),流程见技能 `wincc-text-i18n-workflow` |
13
+
14
+ **第一原则与 PLC 侧相同:先要"软件里能导出的东西"**。触摸屏这边对应的是:画面清单/变量表(软件里能导 CSV 或看变量表)——导出来对账,比在工程里挖快一个数量级。
15
+
16
+ ## 二、西门子 `.hmismart`(主文件可全解)
17
+
18
+ 它**不是压缩包、也没加密**,是 WinCC flexible SMART 的工程数据库单文件:
19
+
20
+ - **容器**:魔数 `01 0F 00 00 08 02 00 00`,4096 字节严格页对齐。
21
+ - **明文对象记录**:`36` + UUID(16B) + 若干字段 + 名称(UTF-16LE)。UUID 是**对象类别**,同类共用一个;判据是同一 UUID 出现 ≥3 次才算真类型。
22
+ - **压缩块**:gzip 头 + **裸 deflate,没有 CRC/ISIZE 尾部**——标准 `gunzip` 只能解出一小部分,必须 `inflateRaw`。解出的内容是「属性名 + 属性值」序列与软件自带的多语言系统消息。
23
+ - **内嵌 DDL**:`Hmi*` 表的建表脚本明文可读(HmiScreenTable / HmiTagTable / HmiConnectionTable / HmiScreenItemTable …),即工程数据的逻辑模型——**查某张表有哪些字段,先看它**。
24
+ - **一次跑完**:`assets/hmismart-decode.mjs`(原始产物)+ `assets/hmismart-report.mjs`(人读报告),产物落 `<工作区>\解码归档\<工程名>\HMI解码\`,只读源文件。
25
+ - **新旧差异**:`assets/hmismart-diff.mjs`(工具 `hmi-diff-project`)——比 ①起始画面配置 ②画面表记录 ③画面名清单 ④配套文件差异。**"重启后跳到别的画面"先看第 1 节**:`StartScreenId` 指向 `Deleted=true` 的画面时,面板启动找不到它就退到列表里的其它画面。
26
+ - **查某个地址/变量时别做大范围 grep**:含压缩块原文的几份产物里宽 pattern(如 `VW`)会刷出几百行垃圾、把上下文冲掉;要查就用具体串。另:**地址表里出现 ≠ 画面绑定**,要知道挂在哪个控件上,得回对象记录再查一层。
27
+
28
+ ### 两台软件同时唱着的时候:直接读旁系 `.data`(省一次解锁)
29
+
30
+ `.hmismart` 被 WinCC 打开时是**独占锁**(连 `FileShare.ReadWrite` 都打不开)。这时不必关软件,同目录的 `{{GUID}}.data` 就是工程数据的旁路:
31
+
32
+ - 结构:`ES2RT` 头(前 10 字节)+ gzip 流(`1F 8B 08` 起),解出来是 .NET 序列化对象图(20MB 量级),**UTF-8,字符串可读**。
33
+ - 你能直接从中拿到:**变量表全部标签名**(含 `文件夹_N\MW5212` 这类「以地址命名」的标签与中文命名标签)、**连接与驱动名**(如 `driver.Modicon MODBUS Standard V3`)、画面/控件文本。
34
+ - 拿不到:**变量的 Modbus 地址号**(二进制存,工程里没有明文字样)——读不到就直说读不到,别编;判定靠现场按一下看对应位是否变,或让用户从变量表导一份出来。
35
+ - 用法:按具体串搜(`MW5212`、`驱动名`、中文标签名),避开宽 pattern。
36
+
37
+ ## 三、`.srt` 项目文本(主文件锁着时的另一条旁路)
38
+
39
+ - **结构**:头文本 `Sm@RT-Transfer` + 0x0E 起 UTF-16LE 链式记录 `[u16 字符数][字符串]`,按「画面 → 控件引用(按钮_N/文本域_N/IO 域_N)→ 中文文本 → 英文文本」交替存放;另有西门子系统消息区(官方中英对照,无需翻译)。
40
+ - **解析要点**:Node `Buffer.toString('utf16le')` 直接可用;链式记录用「从锚点向前回退找链头 → 正向延伸」法;单条扫描会撞二进制假阳性,必须链式 + 可打印字符校验。
41
+ - **陷阱**:① 中文不要用 GBK 解(UTF-16 才对齐);② 抽「中文→英文」对时跳过元素引用/变量地址/空串;③ 截图「项目文本」页可与 `.srt` 内容逐条吻合,能当真值校准。
42
+ - **旁系文件**:`*.transfer`(UTF-16 XML,含设备型号/页面清单)、`*.data`(见上)、`*.chk/.cnk/.rdf/.pwx`(校验/密码,跳过)。
43
+
44
+ ## 四、繁易 FStudio(多语言另见专篇)
45
+
46
+ - 工具:`hmi-decode`(读工程→扫元件文本)、`fstudio-scan-texts`(只看要翻什么)、`fstudio-i18n`(出对照表 + 可导入文本库的表)。
47
+ - 方法与踩坑见同插件技能 **`fstudio-i18n-workflow`**:列名逐字符一致、元件文字常写死不走文本库、原工程英文槽常是错填。
48
+
49
+ ## 五、交付与边界
50
+
51
+ - 产物约定:`.hmismart` 写 `<工作区>\解码归档\<工程名>\HMI解码\`;FStudio 写 `<工程目录>\多语言产出\`。**不写系统临时目录、不污染工程目录**。
52
+ - 只读源工程;`解码归档` 里的报告是某个版本的快照,拿旧报告对账前先比源文件的**大小 + 修改时间**。
53
+ - 屏上地址为什么长成"不是西门子那种格式"(M 区名、Modbus 号、命名习惯):见 PLC 侧技能 `codesys-teach-workflow` §5——那是工程地址面的事,不在这里重复。