From 288ebf4c90a99f92ef7a35f2cdd3772c4ec7ade8 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 12 Sep 2026 09:32:59 +0800 Subject: [PATCH] fix: isolate canvas CJK strings and guard build encoding Co-authored-by: Cursor --- apps/web/package.json | 3 +- apps/web/scripts/check-canvas-cjk.mjs | 84 +++++++ .../web/scripts/diagnose-topology-encoding.py | 26 +++ apps/web/scripts/migrate-canvas-ui-text.py | 209 ++++++++++++++++++ apps/web/scripts/verify-dist-cjk.py | 7 +- apps/web/src/TopologyCanvas.tsx | 97 ++++---- apps/web/src/canvas-ui-text.ts | 58 +++++ 7 files changed, 431 insertions(+), 53 deletions(-) create mode 100644 apps/web/scripts/check-canvas-cjk.mjs create mode 100644 apps/web/scripts/diagnose-topology-encoding.py create mode 100644 apps/web/scripts/migrate-canvas-ui-text.py create mode 100644 apps/web/src/canvas-ui-text.ts diff --git a/apps/web/package.json b/apps/web/package.json index 3d485ea..208a25b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.json --noEmit && vite build", + "build": "node scripts/check-canvas-cjk.mjs && tsc -p tsconfig.json --noEmit && vite build", + "check:cjk": "node scripts/check-canvas-cjk.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "preview": "vite preview", "test": "vitest run --passWithNoTests" diff --git a/apps/web/scripts/check-canvas-cjk.mjs b/apps/web/scripts/check-canvas-cjk.mjs new file mode 100644 index 0000000..d276546 --- /dev/null +++ b/apps/web/scripts/check-canvas-cjk.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Fail the build if canvas UI CJK is corrupted or missing. + * Root cause we guard against: Windows tools rewriting TopologyCanvas.tsx + * with "?" for multibyte UTF-8, and Latin-1 middot 0xb7 breaking UTF-8. + */ +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const errors = []; + +function mustUtf8(rel) { + const path = join(root, rel); + if (!existsSync(path)) { + errors.push(`missing: ${rel}`); + return null; + } + const buf = readFileSync(path); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buf); + } catch (e) { + errors.push(`${rel}: invalid UTF-8 (${e.message})`); + return null; + } +} + +const labels = mustUtf8("src/canvas-ui-text.ts"); +if (labels) { + const required = [ + "选择", + "平移", + "连线", + "单点", + "整枝", + "气泡", + "卡片", + "撤销", + "未命名", + ]; + for (const s of required) { + if (!labels.includes(s)) errors.push(`canvas-ui-text.ts missing: ${s}`); + } + if (/^\s*\w+:\s*["']\?{2,}["']/m.test(labels)) { + errors.push("canvas-ui-text.ts contains corrupted ?? string literals"); + } +} + +const canvas = mustUtf8("src/TopologyCanvas.tsx"); +if (canvas) { + // Toolbar / controls must use canvasUi — no inline ?? placeholders. + const badUi = [ + ...canvas.matchAll(/\["select",\s*"(\?+)"/g), + ...canvas.matchAll(/\["pan",\s*"(\?+)"/g), + ...canvas.matchAll(/\["link",\s*"(\?+)"/g), + ]; + if (badUi.length) { + errors.push( + "TopologyCanvas.tsx has corrupted tool labels (??). Use canvasUi from canvas-ui-text.ts", + ); + } + if (!canvas.includes("canvas-ui-text") && !canvas.includes("canvasUi")) { + errors.push("TopologyCanvas.tsx must import canvasUi from canvas-ui-text.ts"); + } + // Detect classic corruption: many consecutive ? inside quotes in JSX title/aria + const titleQ = [...canvas.matchAll(/title=\{?["'](\?{2,})["']\}?/g)]; + if (titleQ.length >= 3) { + errors.push( + `TopologyCanvas.tsx looks CJK-corrupted (${titleQ.length} title="??" hits). Restore via canvasUi.`, + ); + } +} + +if (errors.length) { + console.error("[check-canvas-cjk] FAILED:"); + for (const e of errors) console.error(" -", e); + console.error( + "Fix: edit src/canvas-ui-text.ts (UTF-8 only). Never pipe CJK through PowerShell Set-Content.", + ); + process.exit(1); +} + +console.log("[check-canvas-cjk] OK"); diff --git a/apps/web/scripts/diagnose-topology-encoding.py b/apps/web/scripts/diagnose-topology-encoding.py new file mode 100644 index 0000000..c10ac89 --- /dev/null +++ b/apps/web/scripts/diagnose-topology-encoding.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""Diagnose TopologyCanvas.tsx encoding / CJK corruption.""" +from __future__ import annotations + +import re +from pathlib import Path + +p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx" +raw = p.read_bytes() +print("size", len(raw)) +print("bom", raw[:3] == b"\xef\xbb\xbf") +try: + text = raw.decode("utf-8") + print("utf8: OK") +except UnicodeDecodeError as e: + print("utf8: FAIL", e) + print("bad-byte context:", raw[max(0, e.start - 30) : e.start + 30]) + text = raw.decode("latin1") + +quoted = re.findall(r'"\?{2,}"', text) +print("quoted ?? count:", len(quoted)) +print("cjk chars:", len(re.findall(r"[\u4e00-\u9fff]", text))) +for i, line in enumerate(text.splitlines(), 1): + if '["select"' in line or "title=" in line and "?" in line and "??" in line: + if i >= 1180 and i <= 1360: + print(f"{i}: {line.strip()[:100]}") diff --git a/apps/web/scripts/migrate-canvas-ui-text.py b/apps/web/scripts/migrate-canvas-ui-text.py new file mode 100644 index 0000000..fb16a49 --- /dev/null +++ b/apps/web/scripts/migrate-canvas-ui-text.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +"""Rewrite TopologyCanvas.tsx to use canvasUi (no inline CJK). Tolerates broken latin1/0xb7 files.""" +from __future__ import annotations + +import re +from pathlib import Path + +p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx" +raw = p.read_bytes() +try: + text = raw.decode("utf-8") +except UnicodeDecodeError: + text = raw.decode("latin1").replace("\xb7", "·") + +# Drop prior broken import if re-run +text = re.sub( + r'^import \{ canvasUi as U \} from "\./canvas-ui-text";\n', + "", + text, + count=1, + flags=re.M, +) + +# Insert import after last relative import near top +if 'from "./canvas-ui-text"' not in text: + m = re.search(r'(import .+ from "\./[^"]+";\n)', text) + # insert after first block of local imports — after image-cache / node-props style imports + insert_at = None + for m in re.finditer(r'^import .+ from "\./.+";\n', text, flags=re.M): + insert_at = m.end() + if insert_at is None: + raise SystemExit("no local import found to anchor canvas-ui-text import") + text = ( + text[:insert_at] + + 'import { canvasUi as U } from "./canvas-ui-text";\n' + + text[insert_at:] + ) + +subs = [ + # comments + (r"// iframe \?+ Tab \?+", "// iframe: notify parent Tab fullscreen when needed"), + (r"// Free place .+?pin every dragged id", "// Free place -> pin every dragged id"), + (r"// Contain: \?+", "// Contain: fit image inside box, no crop"), + # placeholders / creates + (r'(wrapTextLines\(ctx, n\.text \|\| )"\?+"', r'\1U.unnamed'), + (r'(n\.text \|\| )"\?+"', r"\1U.unnamed"), + (r'(wrapTextLines\(ctx, n\.text \|\| )U\.unnamed', r"\1U.unnamed"), # noop safe + (r'text: "\?+", parentId: parent\b', "text: U.newNode, parentId: parent"), + (r': \{ type: "create_node", text: "\?+" \},', ': { type: "create_node", text: U.newNode },'), + ( + r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1', + "text: U.leftChild,\n parentId: selectedIds[0],\n sidePref: -1", + ), + ( + r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1', + "text: U.rightChild,\n parentId: selectedIds[0],\n sidePref: 1", + ), + ( + r'text: "\?+",\n parentId: parentOfSel', + "text: U.sibling,\n parentId: parentOfSel", + ), + ( + r'text: "\?+",\n \.\.\.\(selectedIds\[0\]', + "text: U.newNode,\n ...(selectedIds[0]", + ), + (r'label: "\?+"', "label: U.relationLabel"), + # toolbar + ( + r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=)"\?+"', + r'\1{U.toolbarAria}', + ), + ( + r'(className="canvas-tool-btn"\n title=)"\?+"(\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n )', + r"\1{U.backRosterTitle}\2{U.backRoster}\3", + ), + (r'\["select", "\?+", "V"\]', '["select", U.toolSelect, "V"]'), + (r'\["pan", "\?+", "H"\]', '["pan", U.toolPan, "H"]'), + (r'\["link", "\?+", "C"\]', '["link", U.toolLink, "C"]'), + # also if already Chinese from partial fix + (r'\["select", "选择", "V"\]', '["select", U.toolSelect, "V"]'), + (r'\["pan", "平移", "H"\]', '["pan", U.toolPan, "H"]'), + (r'\["link", "连线", "C"\]', '["link", U.toolLink, "C"]'), + # drag titles/labels — match mixed ? + latin + ( + r'(dragScope === "node" \? " is-active" : ""\}`\}\n title=)"[^"]*"', + r'\1{U.dragNodeTitle}', + ), + ( + r'(aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )(?:\?+|单点)(\n )', + r"\1{U.dragNode}\2", + ), + ( + r'(dragScope === "branch" \? " is-active" : ""\}`\}\n title=)"[^"]*"', + r'\1{U.dragBranchTitle}', + ), + ( + r'(aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )(?:\?+|整枝)(\n )', + r"\1{U.dragBranch}\2", + ), + ( + r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=)"[^"]*"', + r'\1{U.bubbleTitle}', + ), + ( + r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )(?:\?+|气泡)(\n )', + r"\1{U.bubble}\2", + ), + ( + r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=)"[^"]*"', + r'\1{U.cardTitle}', + ), + ( + r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )(?:\?+|卡片)(\n \n \n )(?:\?+|解钉)(\n )', + r"\1{U.unpin}\2", + ), + ( + r'(className="canvas-tool-btn"\n title=)"[^"]*"(\n disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )(?:\?+|撤销)(\n )', + r"\1{U.undo}\2{U.undo}\3", + ), + # controls + ( + r'(className="canvas-controls"\n role="toolbar"\n aria-label=)"\?+"', + r"\1{U.controlsAria}", + ), + ( + r'title=\{fullscreen \? "[^"]*" : "[^"]*"\}\n aria-label=\{fullscreen \? "[^"]*" : "[^"]*"\}', + "title={fullscreen ? U.exitFullscreen : U.fullscreen}\n aria-label={fullscreen ? U.exitFullscreen : U.fullscreen}", + ), + ( + r'title="[^"]* \(\+\)"\n aria-label="[^"]*"', + "title={U.zoomInTitle}\n aria-label={U.zoomIn}", + ), + ( + r'title="[^"]* \(-\)"\n aria-label="[^"]*"', + "title={U.zoomOutTitle}\n aria-label={U.zoomOut}", + ), + ( + r'title="[^"]* \(F\)"\n aria-label="[^"]*"', + "title={U.fitTitle}\n aria-label={U.fit}", + ), + ( + r'title=\{locked \? "[^"]* \(L\)" : "[^"]* \(L\)"\}\n aria-label=\{locked \? "[^"]*" : "[^"]*"\}', + "title={locked ? U.unlockTitle : U.lockTitle}\n aria-label={locked ? U.unlock : U.lock}", + ), + # handles + ( + r'const text = side < 0 \? "[^"]*" : "[^"]*";', + "const text = side < 0 ? U.leftChild : U.rightChild;", + ), + ( + r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=)"[^"]*"', + r"\1{U.addLeftChildTitle}", + ), + ( + r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=)"[^"]*"', + r"\1{U.addRightChildTitle}", + ), + # hint + ( + r"""\{locked + \? "[^"]*" + : dragScope === "node" + \? "[^"]*" + : "[^"]*"\}""", + """{locked + ? U.hintLocked + : dragScope === "node" + ? U.hintNodeDrag + : U.hintBranchDrag}""", + ), +] + +for pat, repl in subs: + text, n = re.subn(pat, repl, text, count=0) + print(f"x{n}: {pat[:50]}") + +# Fix remaining Chinese literals if any known ones remain (after partial restores) +zh_map = { + '"未命名"': "U.unnamed", + '"新节点"': "U.newNode", + '"左侧子主题"': "U.leftChild", + '"右侧子主题"': "U.rightChild", + '"兄弟节点"': "U.sibling", + '"关联"': "U.relationLabel", +} +for a, b in zh_map.items(): + if a in text: + text = text.replace(a, b) + print("zh->U:", a) + +# aria-label leftovers as string attrs that should be expressions +text = text.replace('aria-label="画布工具"', "aria-label={U.toolbarAria}") +text = text.replace('aria-label="视图控制"', "aria-label={U.controlsAria}") + +remaining = re.findall(r'"\?{2,}"', text) +btn = re.findall(r">\n\s+\?{2,}\n\s+", text) +print("quoted ?? left:", remaining) +print("button ?? left:", len(btn)) +print("has import:", 'canvas-ui-text' in text) + +p.write_text(text, encoding="utf-8", newline="\n") +print("wrote", p) +if remaining or btn: + raise SystemExit(2) +print("SUCCESS") diff --git a/apps/web/scripts/verify-dist-cjk.py b/apps/web/scripts/verify-dist-cjk.py index 7329a7c..cb43c78 100644 --- a/apps/web/scripts/verify-dist-cjk.py +++ b/apps/web/scripts/verify-dist-cjk.py @@ -2,9 +2,8 @@ from pathlib import Path d = Path(__file__).resolve().parents[1] / "dist" / "assets" -js_path = next(d.glob("index-*.js")) -js = js_path.read_text(encoding="utf-8") +js = next(d.glob("index-*.js")).read_text(encoding="utf-8") needles = ["选择", "平移", "连线", "单点", "整枝", "气泡", "卡片", "解钉", "撤销"] -print(js_path.name) +print(next(d.glob("index-*.js")).name) print({n: (n in js) for n in needles}) -print("corrupt select:", '["select","??"' in js or '["select", "??"' in js) +print("corrupt select:", '["select","??"' in js) diff --git a/apps/web/src/TopologyCanvas.tsx b/apps/web/src/TopologyCanvas.tsx index 498c840..99eb261 100644 --- a/apps/web/src/TopologyCanvas.tsx +++ b/apps/web/src/TopologyCanvas.tsx @@ -24,6 +24,7 @@ import { import { drawPropSheet, NodePropOverlay } from "./node-props"; import { branchPalette, readSystemTheme } from "./system-theme"; import { useAppStore } from "./store"; +import { canvasUi as U } from "./canvas-ui-text"; const NODE_W = 200; @@ -82,7 +83,7 @@ export function TopologyCanvas() { } await el.requestFullscreen(); } catch { - // iframe ??????????? Tab ?? + // iframe: notify parent Tab fullscreen when needed try { window.parent?.postMessage({ type: "dsh-mind-map:fullscreen" }, "*"); } catch { @@ -566,7 +567,7 @@ export function TopologyCanvas() { ctx.fillStyle = theme.labelPrimary; ctx.font = canvasFont(13, 600); ctx.textBaseline = "middle"; - const ph = wrapTextLines(ctx, n.text || "???", boxW - 40, 1); + const ph = wrapTextLines(ctx, n.text || U.unnamed, boxW - 40, 1); ctx.fillText(ph[0] ?? "?", x + 26, y + 18); } } else { @@ -585,7 +586,7 @@ export function TopologyCanvas() { drawNodeShape(ctx, "default", x + contentLeft, contentTop, iw, ih, 8); ctx.clip(); if (img && img.naturalWidth > 0) { - // Contain: ????????????? + // Contain: fit image inside box, no crop const scale = Math.min( iw / img.naturalWidth, ih / img.naturalHeight, @@ -622,7 +623,7 @@ export function TopologyCanvas() { ctx.textBaseline = "top"; const maxW = boxW - contentLeft - 12; const maxLines = dens ? 2 : n.stylePreset === "title" ? 2 : 2; - const lines = wrapTextLines(ctx, n.text || "???", maxW, maxLines); + const lines = wrapTextLines(ctx, n.text || U.unnamed, maxW, maxLines); const lineH = fontSize + 3; const textBlockH = lines.length * lineH; const badges = @@ -815,8 +816,8 @@ export function TopologyCanvas() { const parent = selectedIds[0]; void applyOps([ parent - ? { type: "create_node", text: "???", parentId: parent } - : { type: "create_node", text: "???" }, + ? { type: "create_node", text: U.newNode, parentId: parent } + : { type: "create_node", text: U.newNode }, ]); } if (e.key === "ArrowLeft" && selectedIds[0] && !editing) { @@ -824,7 +825,7 @@ export function TopologyCanvas() { void applyOps([ { type: "create_node", - text: "?????", + text: U.leftChild, parentId: selectedIds[0], sidePref: -1, }, @@ -835,7 +836,7 @@ export function TopologyCanvas() { void applyOps([ { type: "create_node", - text: "?????", + text: U.rightChild, parentId: selectedIds[0], sidePref: 1, }, @@ -860,10 +861,10 @@ export function TopologyCanvas() { parentOfSel ? { type: "create_node", - text: "????", + text: U.sibling, parentId: parentOfSel, } - : { type: "create_node", text: "???" }, + : { type: "create_node", text: U.newNode }, ]); } }; @@ -1073,7 +1074,7 @@ export function TopologyCanvas() { from: fromId, to: hit, kind: "relation", - label: "??", + label: U.relationLabel, }, ]); } @@ -1106,7 +1107,7 @@ export function TopologyCanvas() { }, ]); } else { - // Free place ? pin every dragged id (branch = whole subtree, node = one) + // Free place -> pin every dragged id (branch = whole subtree, node = one) const nextPos = { ...positions }; const pinOps: { type: "set_pinned"; @@ -1165,7 +1166,7 @@ export function TopologyCanvas() { void applyOps([ { type: "create_node", - text: "???", + text: U.newNode, ...(selectedIds[0] ? { parentId: selectedIds[0] } : {}), sidePref: world.x < 0 ? -1 : 1, }, @@ -1178,7 +1179,7 @@ export function TopologyCanvas() {
e.stopPropagation()} > {embed ? ( @@ -1187,10 +1188,10 @@ export function TopologyCanvas() {
@@ -1199,9 +1200,9 @@ export function TopologyCanvas() {
{( [ - ["select", "??", "V"], - ["pan", "??", "H"], - ["link", "??", "C"], + ["select", U.toolSelect, "V"], + ["pan", U.toolPan, "H"], + ["link", U.toolLink, "C"], ] as const ).map(([mode, label, key]) => (
@@ -1242,7 +1243,7 @@ export function TopologyCanvas() {
@@ -1289,11 +1290,11 @@ export function TopologyCanvas() {
@@ -1304,14 +1305,14 @@ export function TopologyCanvas() {
e.stopPropagation()} >
); diff --git a/apps/web/src/canvas-ui-text.ts b/apps/web/src/canvas-ui-text.ts new file mode 100644 index 0000000..e64e1eb --- /dev/null +++ b/apps/web/src/canvas-ui-text.ts @@ -0,0 +1,58 @@ +/** + * Canvas UI copy (zh). Keep ALL user-facing CJK here. + * TopologyCanvas.tsx must not embed Chinese literals — they get corrupted + * by Windows/PowerShell default encodings (question-mark placeholders / invalid 0xb7 bytes). + */ +export const canvasUi = { + unnamed: "未命名", + newNode: "新节点", + leftChild: "左侧子主题", + rightChild: "右侧子主题", + sibling: "兄弟节点", + relationLabel: "关联", + + toolbarAria: "画布工具", + controlsAria: "视图控制", + + backRosterTitle: "返回画布列表", + backRoster: "返回", + + toolSelect: "选择", + toolPan: "平移", + toolLink: "连线", + + dragNode: "单点", + dragBranch: "整枝", + dragNodeTitle: "单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点", + dragBranchTitle: "整枝拖拽:节点与子孙一起移动 (B)", + + bubble: "气泡", + card: "卡片", + bubbleTitle: "气泡视图:精简标题", + cardTitle: "卡片视图:展开属性面板", + unpin: "解钉", + unpinTitle: "取消钉住:让选中节点重新参与自动布局", + undo: "撤销", + + fullscreen: "全屏", + exitFullscreen: "退出全屏", + zoomIn: "放大", + zoomInTitle: "放大 (+)", + zoomOut: "缩小", + zoomOutTitle: "缩小 (-)", + fit: "适应画布", + fitTitle: "适应画布 (F)", + lock: "锁定", + unlock: "解锁", + lockTitle: "锁定画布 (L)", + unlockTitle: "解锁画布 (L)", + + addLeftChildTitle: "添加左侧子主题", + addRightChildTitle: "添加右侧子主题", + + hintLocked: "已锁定 · 仅浏览/缩放/平移", + hintNodeDrag: "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消", + hintBranchDrag: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消", +} as const; + +export type CanvasUiKey = keyof typeof canvasUi;