Files
agame/docs/limboai-stable/_i18n/build.py
T
2026-10-15 16:30:44 +08:00

295 lines
13 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.
"""Rebuild the Chinese HTML from translated templates."""
import json, re, sys, glob, html as htmllib
from bs4 import BeautifulSoup, Comment, NavigableString
ROOT = r"C:/Users/yvmou/Downloads/limboai-readthedocs-io-en-stable/limboai-stable"
SRC = ROOT + "/index.html"
OUT = ROOT + "/index.zh.html"
I18N = ROOT + "/_i18n"
sys.path.insert(0, I18N)
from serialize import collect # noqa: E402
CHECK_ONLY = "--check" in sys.argv
soup = BeautifulSoup(open(SRC, encoding="utf-8").read(), "lxml")
blocks = collect(soup)
title_node = soup.find("title")
uniq = json.load(open(I18N + "/uniq.json", encoding="utf-8"))["uniq"]
def key_of(tpl, markers):
return tpl + "\x00" + json.dumps(markers, sort_keys=True)
key2uid = {k: v["uid"] for k, v in uniq.items()}
trans = {}
for f in sorted(glob.glob(I18N + "/trans/batch*.json")):
for k, v in json.load(open(f, encoding="utf-8")).items():
trans[int(k)] = v
for b in blocks:
b["uid"] = key2uid.get(key_of(b["tpl"], b["markers"]))
tok_re = re.compile(r"<(l\d+)>|</l\d*>|<([bi])>|</[bi]>|<(\d+)/>")
def norm(t):
"""normalise the tolerated closing-tag variants written by the translator"""
return re.sub(r"</l\d*>", "</l>", t)
def tokens(t):
"""only the tokens that MUST round-trip identically: opaque ids and anchor ids"""
return sorted(m.group(0) for m in re.finditer(r"<(l\d+)>|</l\d*>|<(\d+)/>", t)
if m.group(2) or m.group(1))
def balanced(t):
"""<b>/<i> pairs must be balanced (extra emphasis is allowed)"""
depth = 0
for m in re.finditer(r"<([bi])>|</([bi])>", t):
depth += 1 if m.group(1) else -1
if depth < 0:
return False
return depth == 0
def markers_ok(b, zh):
zh = norm(zh)
return tokens(b["tpl"]) == tokens(zh) and balanced(zh)
untranslated = [b for b in blocks if b["uid"] not in trans]
bad = [b for b in blocks if b["uid"] in trans and not markers_ok(b, trans[b["uid"]])]
print("blocks:", len(blocks),
"| untranslated:", len(untranslated),
"| marker problems:", len(bad))
for b in bad[:20]:
print("--- uid", b["uid"])
print(" en:", b["tpl"][:200])
print(" zh:", trans[b["uid"]][:200])
if CHECK_ONLY:
sys.exit(0)
def rebuild(tpl, markers):
"""turn a translated template back into HTML"""
tpl = norm(tpl)
out, pos = [], 0
for m in tok_re.finditer(tpl):
out.append(htmllib.escape(tpl[pos:m.start()], quote=False))
g = m.group(0)
if g.startswith("<l"):
out.append(markers[g[2:-1]]) # nested anchor: keep as-is
elif g == "</l>":
out.append("</a>")
elif g.startswith("<b>") or g.startswith("<i>"):
out.append("<strong>" if g.startswith("<b") else "<em>")
elif g.startswith("</b") or g.startswith("</i"):
out.append("</strong>" if g.startswith("</b") else "</em>")
else:
out.append(markers[re.search(r"\d+", g).group(0)])
pos = m.end()
out.append(htmllib.escape(tpl[pos:], quote=False))
return "".join(out)
def replace_anchor_inner(marker_html, new_inner):
"""keep the original <a> attributes + inner wrapper, swap the text"""
frag = BeautifulSoup(marker_html, "html.parser")
root = frag.find(True)
node = root
while True:
kids = [k for k in node.children if not isinstance(k, Comment)]
if len(kids) == 1 and getattr(kids[0], "name", None):
node = kids[0]
else:
break
for k in list(node.children):
k.extract()
inner = BeautifulSoup(new_inner, "html.parser")
for k in list(inner.children):
node.append(k.extract())
return str(root)
def build_block_html(tpl, markers):
parts = re.split(r"(<l\d+>.*?</l\d*>)", norm(tpl), flags=re.S)
out = []
for p in parts:
m = re.fullmatch(r"<l(\d+)>(.*?)</l\d*>", p, flags=re.S)
if m:
inner = rebuild(m.group(2), markers)
out.append(replace_anchor_inner(markers[m.group(1)], inner))
else:
out.append(rebuild(p, markers))
return "".join(out)
def set_inner(el, html_str):
el.clear()
frag = BeautifulSoup(html_str, "html.parser")
for k in list(frag.children):
el.append(k.extract())
collect_ids = set(id(b["node"]) for b in blocks if b["node"] is not None)
# a block that *contains* other collected blocks (e.g. <td><p>…) must keep its own
# markup untouched - its text is rebuilt by those nested blocks
containers = set()
for b in blocks:
n = b["node"]
if n is None:
continue
if any(id(d) in collect_ids for d in n.find_all(True)):
containers.add(id(n))
print("container blocks kept as-is:", len(containers))
fixed = 0
for b in blocks:
uid = b["uid"]
zh = trans.get(uid, b["tpl"])
if uid not in trans or not markers_ok(b, zh):
continue
if id(b["node"]) in containers:
continue
headerlinks = [h.extract() for h in b["node"].find_all("a", class_="headerlink")]
html_str = build_block_html(zh, b["markers"])
set_inner(b["node"], html_str)
for h in headerlinks:
b["node"].append(h)
fixed += 1
if title_node is not None and 1451 in trans:
title_node.string = trans[1451]
# ---------------------------------------------------------------- polish pass
ABBR_TITLE = {
"No return value.": "无返回值。",
"This method has no side effects. It doesn't modify any of the instance's member variables.":
"该方法没有副作用,不会修改实例的任何成员变量。",
"This method should typically be overridden by the user to have any effect.":
"该方法通常需要由用户重写才能生效。",
"This method doesn't need an instance to be called, so it can be called directly using the class name.":
"该方法无需实例即可调用,因此可以直接用类名调用。",
}
for el in soup.find_all(attrs={"title": True}):
t = el.get("title")
if t in ABBR_TITLE:
el["title"] = ABBR_TITLE[t]
# breadcrumb
for li in soup.select("li.breadcrumb-item"):
if li.get_text(strip=True) == "LimboAI 1.0 documentation":
li.string = "LimboAI 1.0 文档"
# footer
foot = soup.find("footer")
if foot:
info = foot.find("div", {"role": "contentinfo"})
if info and info.find("p"):
info.find("p").string = "© 2023 年至今 Serhii Snitsaruk 及 LimboAI 贡献者。"
for child in list(foot.children):
if isinstance(child, NavigableString):
s = str(child)
s = s.replace("Built with", "使用 ")
s = s.replace("using a", " 构建,")
s = s.replace("provided by", " 由 ")
child.replace_with(s)
elif getattr(child, "name", None) == "a" and child.get_text(strip=True) == "theme":
child.string = "主题(theme)"
last = foot.contents[-1] if foot.contents else None
if isinstance(last, NavigableString):
last.replace_with(" 提供。\n")
# code comments inside <pre>
CODE_COMMENTS = {
"# Set common target shared between agents in a group:": "# 设置组内各智能体共享的公共目标:",
"# to prevent errors when the instance is freed.": "# 以避免实例被释放时出错。",
'# - Important: Avoid specifying a type for "obj" in GDScript': '# - 重要:在 GDScript 中不要为 "obj" 指定类型',
"# Access common target shared between agents in a group:": "# 访问组内各智能体共享的公共目标:",
'# Check if the "speed" variable exists:': '# 检查 "speed" 变量是否存在:',
'# Get object instance stored in the "object" variable.': '# 获取存储在 "object" 变量中的对象实例。',
'# Get the value of the "speed" variable, with a default value of 100.0 if not found:':
'# 获取 "speed" 变量的值,如果不存在则默认为 100.0:',
'# Handle the "movement_started" event here.': '# 在这里处理 "movement_started" 事件。',
"# It's quite handy when you need to pass some data to the event handler.":
"# 当你需要向事件处理器传递一些数据时,这非常方便。",
'# Set the behavior tree, or use a load("res://ai/trees/...") call':
'# 设置行为树,或使用 load("res://ai/trees/...") 调用',
'# Set the value of the "speed" variable:': '# 设置 "speed" 变量的值:',
"# Use chained methods and delegation to set up states:": "# 使用链式方法与委托来配置状态:",
"# `cargo` can be passed with the event when calling `dispatch()`.":
"# 调用 `dispatch()` 时可以随事件一起传入 `cargo`。",
"# Assume `hsm` is a LimboHSM instance and `bt_state` is a BTState instance":
"# 假设 `hsm` 是一个 LimboHSM 实例,`bt_state` 是一个 BTState 实例",
"# Called each time this task is ticked (aka executed).": "# 每次该任务被 tick(即被执行)时调用。",
"# Called to generate a display name for the task (requires @tool).":
"# 调用它来为任务生成显示名称(需要 @tool)。",
"# Called to generate a display name for the task.": "# 调用它来为任务生成显示名称。",
"# Called to initialize the task.": "# 调用它来初始化任务。",
"# Called when the task is entered.": "# 当任务被进入时调用。",
"# Called when the task is executed.": "# 当任务被执行时调用。",
"# Called when the task is exited.": "# 当任务被退出时调用。",
"# Example: programmatically link a BT blackboard variable to an HSM blackboard variable":
"# 示例:用代码把 BT 黑板变量链接到 HSM 黑板变量",
"# NOTE: variable names do not need to match": "# 注意:变量名不必相同",
"# Strings returned from this method are displayed as warnings in the editor.":
"# 此方法返回的字符串会作为警告显示在编辑器中。",
"# Task parameters.": "# 任务参数。",
"## AgentGroup node: Manages the shared Blackboard for agents in a group.":
"## AgentGroup 节点:管理组内各智能体共享的 Blackboard。",
'## At minimum, you only need to define the "_tick" method.': '## 至少只需要定义 "_tick" 方法。',
"## Called each frame when this state is active.": "## 当该状态处于活动状态时每帧调用。",
"## Called once, when state is initialized.": "## 状态初始化时调用一次。",
"## Called when state is entered.": "## 当状态被进入时调用。",
"## Called when state is exited.": "## 当状态被退出时调用。",
"## Children of this node are assumed to be agents that belong to a common group.":
"## 该节点的子节点被假定为属于同一组的智能体。",
"## InRange condition checks if the agent is within a range of target,":
"## InRange 条件检查智能体是否处于目标的某个范围内,",
"## Note: Each method declaration is optional.": "## 注意:每个方法声明都是可选的。",
"## PlayAnimation: Play an animation on AnimationPlayer, and wait for it to finish.":
"## PlayAnimation:在 AnimationPlayer 上播放动画,并等待其播放完毕。",
"## Returns FAILURE if the node is not found.": "## 如果找不到该节点,返回 FAILURE。",
"## Returns SUCCESS if the agent is within the defined range;":
"## 如果智能体处于定义的范围内,返回 SUCCESS;",
"## Shows or hides a node and returns SUCCESS.": "## 显示或隐藏一个节点并返回 SUCCESS。",
'## This implementation assumes that each agent has a "BTPlayer" node for AI.':
'## 该实现假定每个智能体都有一个用于 AI 的 "BTPlayer" 节点。',
"## defined by distance_min and distance_max.": "## 该范围由 distance_min 和 distance_max 定义。",
"## otherwise, returns FAILURE.": "## 否则返回 FAILURE。",
}
hits = 0
for pre in soup.find_all("pre"):
for txt in pre.find_all(string=True):
s = str(txt)
orig = s
for en, zh in CODE_COMMENTS.items():
if en in s:
s = s.replace(en, zh)
if s != orig:
txt.replace_with(s)
hits += 1
print("code comment spans translated:", hits)
# translation badge in the sidebar
logo_link = soup.select_one(".wy-side-nav-search > a")
if logo_link is not None:
badge = BeautifulSoup(
'<p style="font-size:0.78em;opacity:0.8;margin:0.35em 0 0.2em;">'
'中文汉化版 · 基于官方英文文档翻译,欢迎对照原文阅读</p>', "html.parser")
for k in list(badge.children):
logo_link.insert_after(k.extract())
out = str(soup)
out = out.replace('<html class="writer-html5" lang="en"',
'<html class="writer-html5" lang="zh-CN"', 1)
out = out.replace("<title>LimboAI 1.0 documentation</title>",
"<title>LimboAI 1.0 中文文档</title>")
open(OUT, "w", encoding="utf-8").write(out)
print("rebuilt blocks:", fixed, "->", OUT)