"""专名本土化:把史料里的英文专名翻成中文(只作用于显示层)。 铁律:**只改显示,不改史料**。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"(? 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)