7.1 伪本地化与溢出检测

译文还没到位,怎么提前知道哪国文案会撑破按钮?伪本地化把源串机械改造成「假的外语」——拉丁字母换成带变音符的形态,再用加长括号标记包起来。本节用一份 TypeScript 内核做这个改造与设计期估算,再内联进单文件 HTML,在无头浏览器里真实测量源串与伪本地化串谁撑破容器,并用 Playwright 断言。

站内那篇独立游戏多语言本地化指南 把本地化的行业与流程讲透了:找谁翻、翻多少种、预算怎么排、法务怎么过。本节绝不重写它,只做它没有的一件事:工程实现。具体说,是在真实译文还躺在译员邮箱里的时候,就先造出一批「看起来像外语、但其实是假的」的字符串,把它们塞进按钮和标签,看谁会撑破。这个技术叫伪本地化(pseudolocalization)。它和第 5.3 节文案膨胀与 UI 适配 是同一件事的两半:5.3 量的是真译文来了之后差多少,本节量的是真译文没来之前哪些地方一定会出问题。

为什么要在译文之前就测

本地化最贵的一课,往往发生在上线前一周:德语版提审,运营截来一张图——商店页的「Buy」按钮被 Bestätigen 撑成了两行,[确认] 标签里的字被截断成 [确...]。这时候返工,改的是 UI 布局、重跑的是所有语言的截图,代价远高于一开始就留够余量。

问题在于:译文是最后才到的。UI 是按中文稿排的固定宽度,等英文、德文、日文一份份回来,才发现长度根本不是一个量级。伪本地化就是把这个反馈环提前——它不问「德语会翻成什么」,而是用一条机械规则把中文源串改造成一个刻意更长、更宽、带变音符的假串,用它当作「最坏情况」的替身去压 UI。如果连假串都撑破,真译文大概率也会。

一次改造:变音符 + 加长括号标记

伪本地化的改造规则只有两条,全部可机读:

  • 变音符替换:把每个拉丁字母换成一个带变音符的形态(a→å、e→é、c→ç……),模拟「非 ASCII 字形」在字体里的实际占位;
  • 加长括号标记:在整串前后各加一段填充符([!!!!!!!! … !!!!!!!!]),模拟翻译常见的长度膨胀,同时让截断一眼可见——只要括号标记被切掉,就知道容器不够宽。

规则写进 config/g07_pseudo.json,连同固定宽度容器(containerWidthPx=100,一枚按钮标签的宽度)与五个源串样例:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "伪本地化与溢出检测模型。口径是「公开可观察机制」的建模假设,不是任何真机字体的实测。伪本地化(pseudolocalization)把源语言字符串机械地改造成「假的外语」:把拉丁字母替换成带变音符的形态(accentMap),再用加长括号标记(bracketOpen/fillChar×padCount…fillChar×padCount/bracketClose)把整串包起来。这样在真实译文到位之前,就能把「按钮会不会撑破、标签会不会截断」提前逼出来。charWidthPx=设计期每字符估算宽度(像素);containerWidthPx=固定宽度容器(按钮/标签)像素宽;samples=源串样例。真实排版宽度由字体与渲染引擎决定,须在真实浏览器里量(W 层)。全整数,无浮点。",
  "bracketOpen": "[",
  "bracketClose": "]",
  "fillChar": "!",
  "padCount": 8,
  "charWidthPx": 9,
  "containerWidthPx": 100,
  "accentMap": {
    "a": "å", "b": "ƀ", "c": "ç", "d": "ð", "e": "é", "f": "ƒ", "g": "ĝ", "h": "ĥ",
    "i": "ì", "j": "ĵ", "k": "ķ", "l": "ļ", "m": "ɱ", "n": "ñ", "o": "ò", "p": "þ",
    "q": "q", "r": "ŕ", "s": "š", "t": "ţ", "u": "ù", "v": "ṽ", "w": "ŵ", "x": "x",
    "y": "ý", "z": "ž",
    "A": "Å", "B": "Ɓ", "C": "Ç", "D": "Ð", "E": "É", "F": "Ƒ", "G": "Ĝ", "H": "Ĥ",
    "I": "Ì", "J": "Ĵ", "K": "Ķ", "L": "Ļ", "M": "Ṁ", "N": "Ñ", "O": "Ò", "P": "Þ",
    "Q": "Q", "R": "Ŕ", "S": "Š", "T": "Ţ", "U": "Ù", "V": "Ṽ", "W": "Ŵ", "X": "X",
    "Y": "Ý", "Z": "Ž"
  },
  "samples": [
    { "id": "confirm", "key": "common.confirm", "text": "Confirm" },
    { "id": "cancel", "key": "common.cancel", "text": "Cancel" },
    { "id": "play", "key": "home.play", "text": "Play" },
    { "id": "buy", "key": "shop.buy", "text": "Buy Now" },
    { "id": "settings", "key": "settings.sound", "text": "Settings" }
  ]
}

P 层:改造与估算内核

下面这段是 P 层(内核层)代码 core/src/g07/pseudo.ts。它干三件事:按 accentMap 逐码点做变音符替换、用加长括号标记把整串包起来、再用「码点数 × 每字符估算宽度」估出宽度并判断是否超过容器。全整数、无浮点,且只把空格、数字、标点原样留下——它们不该被换形。

// file: core/src/g07/pseudo.ts
// tier: P
// verify: node --test 'core/test/g07_pseudo.test.ts'
//
// 伪本地化(pseudolocalization)与溢出检测内核:把源语言字符串机械改造成「假的外语」
// ——拉丁字母按 accentMap 换成带变音符的形态,再用加长括号标记包起来——从而在真实译文
// 到位之前,就能用固定宽度容器把「按钮会不会撑破、标签会不会截断」逼出来。
// 这是**设计期**的整数估算模型(每字符固定宽度,不含字体度量);真实排版宽度由 W 层
// 在浏览器里实测。全整数、无浮点、无 Math.random(只用可擦除语法,禁 enum / namespace)。

export interface Sample {
  readonly id: string;
  readonly key: string;
  readonly text: string;
}
export interface PseudoConfig {
  readonly bracketOpen: string;
  readonly bracketClose: string;
  readonly fillChar: string;
  readonly padCount: number;
  readonly charWidthPx: number;
  readonly containerWidthPx: number;
  readonly accentMap: Readonly<Record<string, string>>;
  readonly samples: readonly Sample[];
}
export interface PseudoRow {
  readonly id: string;
  readonly key: string;
  readonly original: string;
  readonly originalWidthPx: number;
  readonly originalOverflow: boolean;
  readonly pseudo: string;
  readonly pseudoWidthPx: number;
  readonly pseudoOverflow: boolean;
}

/** 变音符替换:逐码点查表,未收录的字符原样保留(空格、数字、标点不变)。 */
export function accent(text: string, cfg: PseudoConfig): string {
  return [...text].map((ch) => cfg.accentMap[ch] ?? ch).join("");
}

/** 伪本地化:加长括号标记 + 变音符核心 + 加长括号标记。 */
export function pseudoize(text: string, cfg: PseudoConfig): string {
  const pad = cfg.fillChar.repeat(cfg.padCount);
  return `${cfg.bracketOpen}${pad} ${accent(text, cfg)} ${pad}${cfg.bracketClose}`;
}

/** 设计期宽度估算:码点数 × 每字符估算宽度,整数乘法。 */
export function estWidthPx(text: string, cfg: PseudoConfig): number {
  return [...text].length * cfg.charWidthPx;
}

/** 逐样例:源串与伪本地化串的估算宽度与溢出标志。 */
export function rows(cfg: PseudoConfig): PseudoRow[] {
  return cfg.samples.map((s) => {
    const ps = pseudoize(s.text, cfg);
    const ow = estWidthPx(s.text, cfg);
    const pw = estWidthPx(ps, cfg);
    return { id: s.id, key: s.key, original: s.text, originalWidthPx: ow,
      originalOverflow: ow > cfg.containerWidthPx, pseudo: ps, pseudoWidthPx: pw,
      pseudoOverflow: pw > cfg.containerWidthPx };
  });
}

/** 伪本地化后溢出的样例 id 列表(按 config 顺序)。 */
export function overflowingPseudo(cfg: PseudoConfig): string[] {
  return rows(cfg).filter((r) => r.pseudoOverflow).map((r) => r.id);
}

/** 摘要:与浏览器侧实测结果逐字符比对的确定性指纹。 */
export function digest(cfg: PseudoConfig): string {
  return rows(cfg).map((r) =>
    `${r.id}:${r.originalWidthPx}:${r.originalOverflow ? 1 : 0}:`
    + `${r.pseudoWidthPx}:${r.pseudoOverflow ? 1 : 0}`).join("|");
}

配套测试 core/test/g07_pseudo.test.ts 把「源串安全、伪本地化串一律撑破」写死成断言。同机实跑:

$ node --test 'core/test/g07_pseudo.test.ts'
✔ 变音符替换:字母逐码点换形,空格与标点原样保留
✔ 伪本地化:加长括号标记 + 变音符核心 + 加长括号标记
✔ 估算宽度:码点数 × 每字符估算宽度,整数乘法
✔ 逐样例:源串安全、伪本地化串一律撑破 100px 容器
✔ 溢出集合:伪本地化后全部样例溢出(源串一个都不溢出)
✔ 摘要:确定性指纹逐字符可复算
ℹ tests 6
ℹ pass 6
ℹ fail 0

内核复算出的确定性指纹是 confirm:63:0:243:1|cancel:54:0:234:1|play:36:0:216:1|buy:63:0:243:1|settings:72:0:252:1——五个源串估算宽度 3672px 全部安全,五个伪本地化串 216252px 全部撑破 100px 容器。

W 层:浏览器里量它到底撑不撑破

设计期估算终归是估算。把同一份内核内联进单文件 HTML,就能在真实浏览器里渲染源串与它的伪本地化串、量实际宽度、判断谁撑破容器。下面这段是 W 层(网页层)构建脚本 web/build_g07_pseudo.mjs,它 import 复用 web/pack.mjs 的擦除与打包逻辑,把 core/src/g07/pseudo.ts 内联进产物:

#!/usr/bin/env node
// file: web/build_g07_pseudo.mjs
// tier: W
// verify: node web/build_g07_pseudo.mjs
// 把 core/src/g07/pseudo.ts 打成单文件 HTML:伪本地化与溢出检测原型。产物 web/dist/g07.html。
// 同一句源文案与它的伪本地化串分别塞进固定宽度容器,浏览器里真实测量谁撑破、谁安全。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "g07_pseudo.json"), "utf8"));
const js = bundle(ROOT, ["core/src/g07/pseudo.ts"]);
const CONS = inlineConfig(cfg);
const cards = cfg.samples.map((s) =>
  `  <div class="card"><div class="k">${s.key} · ${s.id}</div>
    <div class="box" style="width:${cfg.containerWidthPx}px"><span class="lbl" id="orig-${s.id}"></span></div>
    <div class="box" style="width:${cfg.containerWidthPx}px"><span class="lbl" id="psd-${s.id}"></span></div></div>`).join("\n");
const script = `const C = ${CONS};
${js}
await document.fonts.ready;
const $ = (id) => document.getElementById(id);
for (const s of C.samples) {
  $("orig-" + s.id).textContent = s.text;
  $("psd-" + s.id).textContent = pseudoize(s.text, C);
}
await document.fonts.ready;
const data = C.samples.map((s) => {
  const ow = Math.round($("orig-" + s.id).getBoundingClientRect().width);
  const pw = Math.round($("psd-" + s.id).getBoundingClientRect().width);
  const ps = pseudoize(s.text, C);
  return { id: s.id, key: s.key, original: s.text, pseudo: ps,
    estOriginalPx: estWidthPx(s.text, C), estPseudoPx: estWidthPx(ps, C),
    originalWidthPx: ow, originalOverflow: ow > C.containerWidthPx,
    pseudoWidthPx: pw, pseudoOverflow: pw > C.containerWidthPx };
});
const cv = $("cv"); cv.width = 720; cv.height = 20 + data.length * 44;
const ctx = cv.getContext("2d");
ctx.fillStyle = "#111827"; ctx.fillRect(0, 0, cv.width, cv.height);
const maxW = Math.max(...data.map((r) => Math.max(r.originalWidthPx, r.pseudoWidthPx))) || 1;
const scale = 460 / maxW;
data.forEach((r, i) => {
  const y = 12 + i * 44;
  const wo = Math.max(1, Math.round(r.originalWidthPx * scale));
  const wp = Math.max(1, Math.round(r.pseudoWidthPx * scale));
  ctx.fillStyle = r.originalOverflow ? "#ef4444" : "#38bdf8"; ctx.fillRect(150, y, wo, 14);
  ctx.fillStyle = r.pseudoOverflow ? "#ef4444" : "#38bdf8"; ctx.fillRect(150, y + 18, wp, 14);
  ctx.fillStyle = "#e2e8f0"; ctx.font = "12px sans-serif";
  ctx.fillText(r.id + "  src " + r.originalWidthPx + "px / pseudo " + r.pseudoWidthPx + "px", 16, y + 22);
});
ctx.strokeStyle = "#64748b"; ctx.beginPath();
ctx.moveTo(150 + Math.round(C.containerWidthPx * scale), 8);
ctx.lineTo(150 + Math.round(C.containerWidthPx * scale), cv.height - 4); ctx.stroke();
globalThis.__pebble = { containerWidthPx: C.containerWidthPx, rows: data, digest: digest(C), ready: true };
`;
const css = `.box { display: block; height: 26px; line-height: 26px; margin: 4px 0; border: 1px solid #334155; border-radius: 6px; overflow: hidden; white-space: nowrap; }
.lbl { display: inline-block; white-space: nowrap; padding: 0 2px; }`;
const body = `  <h1>《合成小岛》· 伪本地化与溢出检测</h1>
  <div class="sub">复用 core/src/g07/pseudo.ts:固定 ${cfg.containerWidthPx}px 容器,上=源串,下=伪本地化串</div>
${cards}
  <div class="card"><div class="k">浏览器实测宽度(px,灰线=容器宽度 ${cfg.containerWidthPx}px;上=源串,下=伪本地化串)</div>
    <canvas id="cv"></canvas></div>`;
const bytes = writePage(join(ROOT, "web/dist/g07.html"), page({ title: "《合成小岛》伪本地化与溢出检测 · g07", body, script, css }));
console.log(JSON.stringify({ html: "web/dist/g07.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));

web/verify_g07_pseudo.mjs 用 Playwright 无头打开产物、量源串与伪本地化串的实际宽度、断言「源串一个都不溢出、伪本地化串一律溢出」,并把结果截到 out/shots/。它先开一个预热页,规避冷启动首个页面测量与 canvas 像素恒空的抖动;canvas 断言放宽为 >= 1 px,不写死严格相等:

#!/usr/bin/env node
// file: web/verify_g07_pseudo.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_g07_pseudo.mjs web/dist/g07.html
// W 层验收:Playwright 无头打开原型,真实测量源串与伪本地化串的宽度,断言「源串安全、
// 伪本地化串一律撑破容器」,且实测溢出集合与 P 层估算一致。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/g07.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
// 冷启动首个页面字体/光栅可能未就绪:先开一个预热页,避免首个页面测量或 canvas 像素恒空。
const warm = await browser.newPage(); await warm.goto(url); await warm.close();
const errors = []; const page = await browser.newPage({ viewport: { width: 820, height: 1100 } });
page.on("pageerror", (e) => errors.push(String(e))); await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.ready === true, null, { timeout: 15000 });
const state = await page.evaluate(() => globalThis.__pebble);
const shot = join(ROOT, "out/shots/g07_pseudo.png");
await page.screenshot({ path: shot, fullPage: true });
// 逐样例真实宽度与溢出标志(重试一次,规避冷启动测量抖动)。
const measure = () => page.evaluate(() => {
  const out = [];
  for (const r of globalThis.__pebble.rows) {
    const ow = Math.round(document.getElementById("orig-" + r.id).getBoundingClientRect().width);
    const pw = Math.round(document.getElementById("psd-" + r.id).getBoundingClientRect().width);
    out.push({ id: r.id, originalWidthPx: ow, originalOverflow: ow > globalThis.__pebble.containerWidthPx,
      pseudoWidthPx: pw, pseudoOverflow: pw > globalThis.__pebble.containerWidthPx });
  }
  return out;
});
let measured = await measure();
if (measured.some((m) => m.pseudoWidthPx === 0)) measured = await measure();
// canvas 非背景像素(#111827 为底),放宽为 >= 1。
const drawn = await page.evaluate(() => {
  const cv = document.getElementById("cv"); const ctx = cv.getContext("2d");
  const d = ctx.getImageData(0, 0, cv.width, cv.height).data; let n = 0;
  for (let i = 0; i < d.length; i += 4) if (!(d[i] === 17 && d[i + 1] === 24 && d[i + 2] === 39)) n += 1;
  return n;
});
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
const byId = Object.fromEntries(measured.map((m) => [m.id, m]));
// 方向:源串一个都不溢出,伪本地化串一律溢出。
const overflowOriginal = measured.filter((m) => m.originalOverflow).map((m) => m.id);
const overflowPseudo = measured.filter((m) => m.pseudoOverflow).map((m) => m.id);
assert.deepEqual(overflowOriginal, [], "源串不应有任何溢出");
assert.deepEqual(overflowPseudo, ["confirm", "cancel", "play", "buy", "settings"], "伪本地化串应全部溢出");
assert.equal(byId["settings"].originalOverflow, false, "最长的源串 Settings 也不应溢出");
assert.equal(byId["play"].pseudoOverflow, true, "最短的伪本地化串 Play 也应溢出");
// 实测溢出集合与 P 层估算一致。
const estPseudoOverflow = state.rows.filter((r) => r.estPseudoPx > state.containerWidthPx).map((r) => r.id);
assert.deepEqual(estPseudoOverflow, overflowPseudo, "实测溢出集合应与 P 层估算一致");
// 伪本地化串确实含变音符(非 ASCII)与加长括号标记。
const play = state.rows.find((r) => r.id === "play");
assert.ok([...play.pseudo].some((ch) => ch.codePointAt(0) > 0x7f), "伪本地化串应含变音符");
assert.ok(play.pseudo.startsWith("[") && play.pseudo.endsWith("]"), "伪本地化串应含加长括号标记");
assert.ok(drawn >= 1, `canvas 应有绘制内容,实测 ${drawn} px`);
console.log(JSON.stringify({ page: "web/dist/g07.html", containerWidthPx: state.containerWidthPx,
  est: state.rows.map((r) => [r.id, r.estOriginalPx, r.estPseudoPx]),
  real: measured.map((m) => [m.id, m.originalWidthPx, m.pseudoWidthPx]),
  overflowOriginal, overflowPseudo, digest: state.digest, canvasPx: drawn,
  screenshot: "out/shots/g07_pseudo.png" }, null, 2));
console.log("SMOKE-W-g07: PASS");

实测结果:估算 vs 真实

在本机跑 node web/build_g07_pseudo.mjs 产出 web/dist/g07.html,再用 NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_g07_pseudo.mjs web/dist/g07.html 验收,输出如下(同机相对量级,绝对数值不可移植):

est:  confirm 63/243 · cancel 54/234 · play 36/216 · buy 63/243 · settings 72/252   (估算 源/伪)
real: confirm 62/157 · cancel 54/148 · play 34/129 · buy 68/165 · settings 64/158   (实测 源/伪)
overflowOriginal = []                                   容器 100px
overflowPseudo   = ["confirm","cancel","play","buy","settings"]
SMOKE-W-g07: PASS

两点值得看。方向完全一致:实测里源串最大 68px(buy)仍安全,伪本地化串最小 129px(play)已经撑破 100px——五个源串一个不溢出、五个假串一律溢出,这正是伪本地化想要的效果:用假串把边界逼出来。但绝对值差得远:估算说伪串 216252px,实测只有 129165px,几乎腰斩。原因是估算把每个码点都按 9px 算,而加长标记里的 ! 与空格实际只有 4~5px 宽。这恰恰是设计期估算的定位——它把「谁危险」判对(源串安全、假串溢出),但不负责把像素算准;像素得交给 W 层的真实浏览器。

X 层:真实字体度量不可复现

边界必须划清。上面量的是原型环境里的宽度:给定字号与系统字体,浏览器怎么排。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真机字体度量(不同设备预装字体不同,同一段伪本地化串在 A 机与 B 机的换行位置可能不一样);
  • 系统字号缩放与无障碍设置(用户把字号调大 150%,所有宽度都会变);
  • 文本断行与连字符规则(长复合词是否允许断行、在哪里断,随排版引擎变化)。

工程上正确的做法是:把可复现的溢出边界与不可复现的真机排版分开——伪本地化原型用来回答「哪些键危险、要留多少余量」,真机排版靠伪本地化串 + 真机截图兜底。

这一节把「译文没来就测排版」这件事做成了可跑、可断言的工程:改造规则进 config、估算进 P 层内核、真实测量进 W 层 Playwright,三道都留下可复算的指纹与截图。下一节我们换个角度,讲译文来了之后怎么判它合不合格——语言 QA 清单与母语审校。

阅读导航:上一节:6.3 日期、数字、货币与 RTL · 下一节:7.2 语言 QA 与母语审校 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制