- 新增 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 项回归)
241 lines
9.4 KiB
Python
241 lines
9.4 KiB
Python
"""专名本土化:把史料里的英文专名翻成中文(只作用于显示层)。
|
||
|
||
铁律:**只改显示,不改史料**。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)
|