295 lines
13 KiB
Python
295 lines
13 KiB
Python
"""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)
|