Files
dwarf-fortress-annals/dfannals/localize.py
T
Chen Yi 16b476167e 专名本土化(信达雅):冻结译名表 + 渲染层,并修掉校验器的正则盲点
- 新增 dfannals/glossary.py(译名表)与 dfannals/localize.py(渲染层)
  人名=音译+绰号意译(Ral Fastenhatchets→拉尔·缚斧、朗古德·商熊「剖胎之寒」),
  地名整体意译(Fordedwinds→涉风渡、The Volcano Of Ravens→渡鸦火山)
- 生成链路本土化:素材渲染、人物表、扩写白名单全部走译名;已有产物做确定性替换
- 修复 factcheck 的 \b 盲点:Unicode 下汉字也算词字符,紧贴汉字的英文名不被检查
  (曾因此漏掉编造专名)。修好后抓出并定点修掉长版第 1 集的 2 处编造
- 修正 title_form 大小写(YETI→Yeti),此前 Yeti/Goblin 一直漏译
- 新增 tests/test_localize.py(13 项回归)
2026-10-05 22:39:53 +08:00

241 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""专名本土化:把史料里的英文专名翻成中文(只作用于显示层)。
铁律:**只改显示,不改史料**。World 里的英文原名、事件字段、事实校验依据全部保持原样,
译文只在这一层生成,所以:
- 事实校验仍以英文原名为准(译文通过 `Translator.accepted()` 参与校验,不会被当成编造);
- 译名不写回 data/export,不改变 legends 的解析结果,去掉这层也不影响任何已产出的史料。
表里查不到的名字**原样保留**并登记进 `pending`,绝不硬造一个音译——
一个难读的英文名远好过一个每次都不一样的乱译名。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from dfannals import glossary
from dfannals.legends import World
_WORD_SPLIT = re.compile(r"\s+")
_CJK = r"\u4e00-\u9fff\u3000-\u303f\uff00-\uffef"
def split_epithet(name: str) -> tuple[str, str]:
"""拆出 DF 的长称号:`Xxx Yyy the Zzz Of Www` → ('Xxx Yyy', 'the zzz of www')。"""
lowered = name.strip().lower()
marker = " the "
if marker in lowered:
main, epithet = lowered.split(marker, 1)
return main, "the " + epithet
return lowered, ""
def translate_person(name: str) -> str | None:
"""人名:名字音译 + 姓氏意译。查不全就返回 None(宁可保留英文)。"""
if not name:
return None
main, epithet = split_epithet(name)
parts = _WORD_SPLIT.split(main)
stem = glossary.STEMS.get(parts[0])
if stem is None:
return None
surname = ""
if len(parts) > 1:
surname = glossary.SURNAMES.get("".join(parts[1:]), "")
if not surname:
return None
out = stem if not surname else f"{stem}·{surname}"
if epithet:
out += "「" + glossary.EPITHETS.get(epithet, "") + "」"
if not glossary.EPITHETS.get(epithet):
return None
return out
def translate_place(name: str) -> str | None:
"""地名:整体意译;带称号的写成「主名(称号)」。"""
if not name:
return None
main, epithet = split_epithet(name)
base = glossary.PLACES.get(main.replace(" ", ""))
if base is None:
return None
if epithet:
extra = glossary.EPITHETS.get(epithet)
return f"{base}({extra})" if extra else None
return base
def translate_group(name: str) -> str | None:
if not name:
return None
return glossary.GROUPS.get(name.strip().lower())
def translate_race(value: str) -> str:
"""种族值(DWARF、EAGLE_MAN)→ 中文;查不到原样返回。"""
if not value:
return value
return glossary.RACES.get(value.strip().upper(), value)
def translate_site_type(value: str) -> str:
if not value:
return value
return glossary.SITE_TYPES.get(value.strip().lower(), value)
@dataclass
class Translator:
"""把一整个世界的专名译好,供渲染与校验共用。"""
people: dict[str, str] = field(default_factory=dict) # 英文原名 → 中文
places: dict[str, str] = field(default_factory=dict)
groups: dict[str, str] = field(default_factory=dict)
stems: dict[str, str] = field(default_factory=dict) # 单用名(正文里绝大多数是简称)
surnames: dict[str, str] = field(default_factory=dict) # 单用姓(对白里互相叫姓)
races: dict[str, str] = field(default_factory=dict) # 含全大写与首字母大写两种写法
pending: list[str] = field(default_factory=list) # 表里没有、原样保留的名字
def person(self, name: str) -> str:
return self.people.get(name, name)
def place(self, name: str) -> str:
return self.places.get(name, name)
def group(self, name: str) -> str:
return self.groups.get(name, name)
def _pairs(self) -> list[tuple[str, str]]:
"""按英文原名长度降序:先替换长的,避免 `Ral` 抢在 `Ral Fastenhatchets` 前面。
顺序很重要:全名 → 单独的姓 → 单独的名。换掉全名后正文里已无拉丁字母,
后面两步不会再动它。
"""
full = list(self.people.items()) + list(self.places.items()) + list(self.groups.items())
full.sort(key=lambda kv: -len(kv[0]))
tail = list(self.surnames.items()) + list(self.stems.items())
tail.sort(key=lambda kv: -len(kv[0]))
return full + tail
def render(self, text: str) -> str:
"""把已有正文里的英文专名换成中文,并收拾替换后多余的空格。"""
for source, target in self._pairs():
text = re.sub(rf"(?<![A-Za-z]){re.escape(source)}(?![A-Za-z])", target, text)
# 种族:既要处理枚举值 EAGLE_MAN,也要处理正文里的 Yeti/Eagle_man
for source, target in sorted(self.races.items(), key=lambda kv: -len(kv[0])):
text = re.sub(rf"(?<![A-Za-z_]){re.escape(source)}(?![A-Za-z_])", target, text)
# 地点类型是小写短语(forest retreat、dark pits)
for source, target in sorted(glossary.SITE_TYPES.items(), key=lambda kv: -len(kv[0])):
text = re.sub(rf"(?<![A-Za-z]){re.escape(source)}(?![A-Za-z])", target, text,
flags=re.IGNORECASE)
return tidy_spaces(text)
def accepted(self) -> set[str]:
"""译名集合:事实校验要把它们当已知名字,否则会把译文当成凭空编造。"""
out: set[str] = set()
for target in [*self.people.values(), *self.places.values(), *self.groups.values()]:
out.add(target)
out.update(re.split(r"[·()「」,、]", target))
return {name for name in out if name}
def coverage(self) -> str:
rows = [
(
f"译名覆盖:人物 {len(self.people)} 个|地点 {len(self.places)} 个|"
f"势力 {len(self.groups)} 个"
),
]
if self.pending:
rows.append(f"待译(原样保留){len(self.pending)} 个:" + "、".join(self.pending[:12]))
return "\n".join(rows)
def tidy_spaces(text: str) -> str:
"""去掉中日韩字符之间被替换后留下的空格:`拉尔 说` → `拉尔说`。
英文专名之间、以及英文与汉字之间的空格保留(`Ral Fastenhatchets` 用得上)。
"""
text = re.sub(rf"(?<=[{_CJK}])[ \t]+(?=[{_CJK}])", "", text)
text = re.sub(rf"(?<=[{_CJK}])[ \t]+(?=[,。、;:!?)】」])", "", text)
text = re.sub(rf"(?<=[(【「])[ \t]+(?=[{_CJK}])", "", text)
return text
def title_form(value: str) -> str:
"""YETI → Yeti、EAGLE_MAN → Eagle_man、fastenhatchets → Fastenhatchets。
两个坑都踩过:
- 只改首字母不转小写,`YETI` 会得到 `YETI`,永远匹配不上正文里的 `Yeti`;
- 按下划线逐段首字母大写,`EAGLE_MAN` 会得到 `Eagle_Man`,
而史料渲染惯例是 `Eagle_man`(见 legends.pretty)。
"""
lowered = value.lower()
return lowered[:1].upper() + lowered[1:]
def build(world: World) -> Translator:
"""按世界里的实际名字建翻译器。"""
translator = Translator()
# 名与姓都单独收一份:正文里大量使用简称("Ral")和对白里只呼姓("Fastenhatchets")
translator.stems = {title_form(en): zh for en, zh in glossary.STEMS.items()}
translator.surnames = {title_form(en): zh for en, zh in glossary.SURNAMES.items()}
translator.races = {}
for en, zh in glossary.RACES.items():
translator.races[en] = zh
translator.races[title_form(en)] = zh
# 逐段大写的写法(Eagle_Man)也登记:模型两种都可能写
translator.races["_".join(p[:1].upper() + p[1:].lower() for p in en.split("_"))] = zh
for figure in world.figures.values():
raw = figure.name
if not raw:
continue
translated = translate_person(raw)
shown = _display(raw)
if translated:
translator.people[shown] = translated
translator.people[raw] = translated
elif _WORD_SPLIT.split(shown.lower())[0] in glossary.STEMS:
# 名字能读、姓氏没词条:这是真正需要补的缺口,登记下来
translator.pending.append(shown)
for site in world.sites.values():
raw = site.name
if not raw:
continue
translated = translate_place(raw)
shown = _display(raw)
if translated:
translator.places[shown] = translated
translator.places[raw] = translated
for entity in world.entities.values():
raw = entity.name
if not raw:
continue
translated = translate_group(raw)
shown = _display(raw)
if translated:
translator.groups[shown] = translated
translator.groups[raw] = translated
return translator
_CACHE: dict[int, tuple[World, Translator]] = {}
def for_world(world: World) -> Translator:
"""带缓存的建立器:一次运行里反复渲染素材,不能每次都重建 4 万个人名。"""
hit = _CACHE.get(id(world))
if hit is not None and hit[0] is world:
return hit[1]
translator = build(world)
_CACHE[id(world)] = (world, translator)
return translator
def _display(raw: str) -> str:
"""史料的程序化名全小写,渲染层会做首字母大写;译名要按展示形式建索引。"""
from dfannals.legends import pretty
return pretty(raw)