From f66ce374781806cff7b7bee90ee2b647e1f6e304 Mon Sep 17 00:00:00 2001 From: xin61 Date: Wed, 8 Jul 2026 15:59:39 +0800 Subject: [PATCH] feat(editor): add translation launcher workflow --- .gitignore | 15 +- AGENTS.md | 141 ++++ README.md | 63 ++ epub_delivery/build_epub_variants.py | 380 +++++++++ epub_delivery/epub_delivery_recipe.md | 68 ++ rules/translation_rules.md | 199 +++++ templates/continue_message_template.md | 28 + templates/delivery_message_template.md | 37 + templates/new_volume_start_template.md | 96 +++ templates/series_style_template.md | 75 ++ templates/startup_message_template.md | 48 ++ tools/epub_review_editor/MAINTENANCE.md | 109 ++- tools/epub_review_editor/README.md | 49 +- tools/epub_review_editor/open_editor.ps1 | 134 +++ tools/epub_review_editor/server.py | 915 ++++++++++++++++++++- tools/epub_review_editor/static/app.js | 769 ++++++++++++++++- tools/epub_review_editor/static/index.html | 304 +++++-- tools/epub_review_editor/static/style.css | 649 +++++++++++++-- tools/epub_review_editor/version.py | 2 +- 打开EPUB审校器.cmd | 11 + 20 files changed, 3851 insertions(+), 241 deletions(-) create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 epub_delivery/build_epub_variants.py create mode 100644 epub_delivery/epub_delivery_recipe.md create mode 100644 rules/translation_rules.md create mode 100644 templates/continue_message_template.md create mode 100644 templates/delivery_message_template.md create mode 100644 templates/new_volume_start_template.md create mode 100644 templates/series_style_template.md create mode 100644 templates/startup_message_template.md create mode 100644 tools/epub_review_editor/open_editor.ps1 create mode 100644 打开EPUB审校器.cmd diff --git a/.gitignore b/.gitignore index a544198..2ed0d72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,21 @@ -# This repository is scoped to the bilingual EPUB review editor. +# This repository is scoped to the bilingual EPUB review editor and shared workflow docs. # Keep translation corpora, EPUBs, review sessions, and generated outputs out of Git. * !.gitignore +!AGENTS.md +!README.md +!打开EPUB审校器.cmd +!rules/ +!rules/** +!templates/ +!templates/** +!epub_delivery/ +!epub_delivery/** + +epub_delivery/**/__pycache__/ +epub_delivery/**/*.py[cod] + !tools/ tools/* !tools/epub_review_editor/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2e0a5db --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,141 @@ +# 轻小说翻译长期项目工作流 + +本项目用于持续推进日语轻小说的简中翻译、复审、润色与 EPUB 交付。开始任何任务前,先阅读本文件,再读取 `rules/translation_rules.md`、当前系列的 `series-style.md` 和正式术语表。 + +## 必读顺序 + +1. `rules/translation_rules.md` +2. 当前系列目录下的 `series-style.md` +3. 当前系列目录下的 `mingcibiao.json` +4. 当前卷目录下与任务阶段相关的文件 + +如果任务是新卷启动,继续读取 `templates/new_volume_start_template.md`。如果任务是封装交付,继续读取 `epub_delivery/epub_delivery_recipe.md`。 + +## 项目结构 + +标准结构如下: + +```text + + \ + mingcibiao.json + mingcibiao.ai.json + mingcibiao.candidates.json + series-style.md + \ + text\ + preprocess_text\ + trans_text\ + final_text\ + deliverables\ +``` + +## 长期工作流 + +1. 导入源文件,保留段落编号、div 结构、ruby 信息和必要标签。 +2. 扫描正文与 ruby,整理候选词到 `mingcibiao.candidates.json`。 +3. 等用户确认后,将候选词合并进 `mingcibiao.json`。 +4. 锁词后进行正文首译,首译写入 `trans_text/chunks`。 +5. 逐段复审,复审写入 `trans_text/reviewed_chunks`。 +6. 复审完成后恢复 HTML,写入 `final_text`。 +7. 交付前检查术语、残留日文、ruby、段落覆盖率和 HTML 结构。 +8. 需要 EPUB 时,使用 `epub_delivery/build_epub_variants.py` 生成纯译版和/或中日双语版。 + +## 默认多 Agent 协作分工 + +除非用户明确指定单线程处理,长卷、跨文件、翻译复审或 EPUB 交付任务默认按多 Agent 流水线协作: + +- 统筹 Agent:读取本文件、`translation_rules.md`、`series-style.md`、`mingcibiao.json`,确认当前阶段、输入文件、输出目录、风险点与暂停条件。 +- 术语 Agent:扫描原文、ruby、标题、技能名、人名地名与高频特殊表达,输出候选术语;只提出候选,不擅自写入正式 `mingcibiao.json`。 +- 首译 Agent:只处理明确分配的 chunk 范围,严格使用已锁定术语、系列风格和段落编号。 +- 复审 Agent:逐段对照源文、首译、术语表和风格文件,修正误译、漏译、日式语序、角色口吻漂移。 +- 格式恢复 Agent:恢复 HTML,保留 div、ruby、段落编号、必要标签和图片引用。 +- QA Agent:执行术语命中、残留日文、ruby、段落覆盖率、HTML 结构、空段落和交付文件检查;只读审查时只给报告,不改文件。 +- 交付 Agent:仅在 QA 通过或用户确认风险后,生成纯译版和/或中日双语版。 + +并行 Agent 必须写入各自负责的目录或报告,不跨范围修改。发现正式术语、结构、交付形式或规则冲突时,暂停并交给统筹 Agent 汇总后请用户确认。 + +审校器功能开发或维护任务应先读取 `tools/epub_review_editor/MAINTENANCE.md`,按工程维护、回归测试和版本规则处理,并默认采用“后端/API、前端交互、文档/工作流、QA 验收”的多 Agent 协作,不套用正文翻译流水线。 + +## 翻译与润色原则 + +- 输出必须是自然、流畅的简体中文轻小说文风。 +- 以意义优先,再调整成中文语序,不要贴着日语句序硬译。 +- 对话要符合角色性格,不要把所有角色写成同一种口吻。 +- 术语、人名、地名、组织名优先服从 `mingcibiao.json`。 +- 如果发现会影响一致性的新术语,先记录为候选词,不要擅自发明多个译名。 +- 保留 `[Pxxxx]`、`...`、必要的 ruby 和 HTML 标签。 +- 不合并、不拆分原有段落,除非用户明确要求重排结构。 +- 复审必须逐段覆盖,不可以只抽查。 + +## 提示词第一性原理 + +- 输入必须完整但克制:源文、必要上下文、命中术语、系列风格和输出格式要求同时给出;单段重翻不塞整本术语表。 +- 约束必须显式:不要只写“遵守术语表”,必须把本段实际命中的术语列入提示词;未命中的术语不得进入提示。 +- 输出必须可验收:要求保持段落数量、段落编号、HTML/ruby/标签结构,不输出解释、分析或额外段落。 +- 意义优先于字面:先理解语义、人物关系、语气和叙述功能,再改写成自然简中轻小说文风。 +- 复审不同于重译:复审优先保留首译中正确内容,只修误译、漏译、术语、语序、口吻和格式问题。 +- 不让模型自行定规则:遇到术语冲突、角色称谓冲突、ruby 含义不明、结构缺损时,输出问题或候选,不自行发明长期规则。 + +## Ruby 与格式 + +- 纯读音、无额外信息的 ruby,通常直接译成普通中文。 +- 隐含义、别名、外语读音、技能名、称号、世界观术语,应按需要保留为真实 ruby。 +- 不要把有意义的 ruby 改成括号。 +- 不要删除 `` 后再用猜测补回。 +- 双语版中,日文保留为灰色,中文译文紧随其后。 + +## 常用用户指令 + +- `新卷启动`:按 `templates/new_volume_start_template.md` 检查目录、风格文件和术语表。 +- `开始翻译`:进入正文翻译流程。 +- `继续`:按当前阶段自动推进下一步;没有风险点时不要重复解释整套流程。 +- `生成双语版`:生成中日双语 EPUB。 +- `生成翻译版`:生成纯译文 EPUB。 +- `终交付封装` 或 `合并为epub交付给我`:默认生成纯译版和中日双语版。 + +## 交付脚本 + +通用封装脚本位于 `epub_delivery/build_epub_variants.py`。常用命令: + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:SOURCE_EPUB='<源EPUB完整路径>' +$env:NOVEL_ROOT='<小说工程根目录完整路径>' +python '\epub_delivery\build_epub_variants.py' --mode both +``` + +可选模式: + +```powershell +python '\epub_delivery\build_epub_variants.py' --mode translated +python '\epub_delivery\build_epub_variants.py' --mode bilingual +``` + +## 双语 EPUB 网页审校 + +中日双语 EPUB 生成后,如用户需要网页审校、实时修改译文或标记翻译问题,使用 `tools/epub_review_editor/server.py`: + +```powershell +$env:PYTHONIOENCODING='utf-8' +python '\tools\epub_review_editor\server.py' '<中日双语版.epub>' --daemon +``` + +审校器会生成独立 session,默认写入 `epub_review_sessions\`。重点读取: + +- `feedback/translation_feedback.jsonl`:结构化逐条反馈记录 +- `feedback/feedback_for_codex.md`:适合直接纳入后续翻译优化的摘要 +- `exports/*_网页审校版_*.epub`:用户导出的修订版 EPUB + +当用户说“读取我的审校反馈”“根据网页标记优化翻译”或类似指令时,优先读取上述反馈文件,将其中的具体修改、问题类型和长期规则沉淀到当前系列的复审口径、候选术语或风格文件中。正式术语表仍需用户确认后再改。 + +## 决策规则 + +- 如果 `series-style.md` 不存在,先用 `templates/series_style_template.md` 创建,再暂停等待用户填写。 +- 如果 `series-style.md` 为空或明显未完成,先提示用户确认是否继续。 +- 如果用户确认继续,只按已填写部分限定风格,空白部分视为未指定。 +- 名词表、规则冲突、交付形式等会影响全卷一致性的事项,先暂停让用户确认。 +- 用户只说“继续”时,按当前上下文进入下一步,不重新解释流程。 +- 每次完成代码、文档或工作流更新后,先完成必要检查,再将当前仓库同步到 `https://git.diaomin.asia/` 对应远端;如果缺少远端地址、凭据或登录授权,先询问用户,不要静默跳过。 +- 涉及通用工作流、目录结构、术语表格式、验收标准或默认协作方式的变更,必须同步 `rules/translation_rules.md` 与 `README.md`。 +- 涉及网页审校器行为、API、配置、前端交互或 GPT 重翻的变更,必须同步 `tools/epub_review_editor/MAINTENANCE.md` 并按其版本规则发版。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..64a3fc5 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# 最小发布包 + +这是用于对外共享的轻小说翻译工作流最小包。 + +## 包含内容 + +- `AGENTS.md`:完整长期项目工作流,包括默认多 Agent 分工、决策规则与交付入口 +- `rules/translation_rules.md`:通用翻译规则,适用于所有线程和卷 +- `templates/new_volume_start_template.md`:新卷启动检查模板 +- `templates/series_style_template.md`:系列风格模板 +- `templates/startup_message_template.md`:启动消息模板 +- `templates/continue_message_template.md`:继续推进模板 +- `templates/delivery_message_template.md`:双语版与终交付模板 +- `epub_delivery/epub_delivery_recipe.md`:通用 EPUB 交付说明模板 +- `epub_delivery/build_epub_variants.py`:通用双语封装脚本 +- `tools/epub_review_editor/`:可独立部署的 EPUB 网页审校器,支持阅读审校、书架、AI 翻译 EPUB、GPT 重翻、术语表编辑、反馈与导出 +- `tools/epub_review_editor/MAINTENANCE.md`:网页审校器维护、验收、GPT 重翻、术语命中与版本规则 +- `打开EPUB审校器.cmd`:Windows 一键启动入口,会自动准备本地 Python 环境并打开网页工具 + +## 使用建议 + +1. 先读 `AGENTS.md`,再读 `rules/translation_rules.md` +2. 按需要选择 `templates/` 下的对应模板 +3. 若是新系列,先使用 `templates/series_style_template.md` +4. 如果要进行 EPUB 交付,使用 `epub_delivery/` 里的说明和脚本 +5. 复制整个文件夹即可在其他机器上复用 + +## 固定工作流入口 + +复用本包时,默认工作流为: + +1. 先读 `AGENTS.md`,确认当前任务属于新卷启动、首译、复审、恢复、QA、审校器反馈处理或 EPUB 交付。 +2. 再读 `rules/translation_rules.md`,作为所有 Agent 的硬性规则。 +3. 当前系列必须提供 `series-style.md` 与 `mingcibiao.json`。 +4. 长卷任务默认使用多 Agent 分工:统筹、术语、首译、复审、格式恢复、QA、交付。 +5. 正式术语表由用户确认后更新;候选词表不得直接当正式词表使用。 +6. 交付前必须通过术语、残留日文、ruby、段落覆盖率和 HTML 结构检查。 + +## 版本规则 + +本包文档和脚本建议使用语义化版本: + +- PATCH:错字、说明修正、兼容性小修、脚本 bugfix。 +- MINOR:新增向后兼容模板、检查项、可选流程或审校器能力。 +- MAJOR:改变目录结构、正式工作流阶段、术语表格式、交付脚本参数或默认产物行为。 + +发版时至少检查: + +- `AGENTS.md` +- `rules/translation_rules.md` +- `README.md` +- `epub_delivery/epub_delivery_recipe.md` +- `tools/epub_review_editor/MAINTENANCE.md`,如本次包含审校器变化 + +## 仓库同步 + +每次完成代码、文档或工作流更新后,先完成必要检查,再将仓库同步到 `https://git.diaomin.asia/` 对应远端。若当前仓库没有配置完整远端地址,或推送时需要登录、令牌、凭据授权,应先询问用户。 + +## 说明 + +- 这个包刻意不包含原文、词表、chunk 文件和已生成的 EPUB 成品。 +- 文档里的路径均为占位写法,复制到任意位置后都可以按相同结构使用。 +- 它用于复用工作流,不是完整的翻译工程目录。 diff --git a/epub_delivery/build_epub_variants.py b/epub_delivery/build_epub_variants.py new file mode 100644 index 0000000..1ff5647 --- /dev/null +++ b/epub_delivery/build_epub_variants.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import argparse +import html +import json +import os +import re +import zipfile +from pathlib import Path + + +P_RE = re.compile(r"]*)>(.*?)

", re.S | re.I) +H_RE = re.compile(r"]*)>(.*?)", re.S | re.I) +RUBY_RE = re.compile(r"]*>.*?", re.S | re.I) +RB_RE = re.compile(r"]*>(.*?)", re.S | re.I) +RT_RE = re.compile(r"]*>(.*?)
", re.S | re.I) +TAG_RE = re.compile(r"<[^>]+>") +NAV_LABEL_TRANSLATIONS = { + "\u76ee\u6b21": "\u76ee\u5f55", + "\u5c11\u5e74\u671f\u3000\u5341\u4e8c\u6b73\u306e\u521d\u590f": "\u5c11\u5e74\u671f \u5341\u4e8c\u5c81\u521d\u590f", + "\u5c11\u5e74\u671f\u3000\u5341\u4e8c\u6b73\u306e\u76db\u590f": "\u5c11\u5e74\u671f \u5341\u4e8c\u5c81\u76db\u590f", + "\u5c11\u5e74\u671f\u3000\u5341\u4e09\u6b73\u306e\u79cb": "\u5c11\u5e74\u671f \u5341\u4e09\u5c81\u4e4b\u79cb", + "\u7d42\u7ae0": "\u7ec8\u7ae0", + "\u30d8\u30f3\u30c0\u30fc\u30bd\u30f3\u30b9\u30b1\u30fc\u30eb1.0\u3000Ver0.3": "\u4ea8\u5fb7\u68ee\u91cf\u88681.0 Ver0.3", + "\u5965\u4ed8": "\u7248\u6743\u9875", + "\u3042\u3068\u304c\u304d": "\u540e\u8bb0", +} + + +def extract_paragraphs(html: str) -> list[str]: + return [m.group(2) for m in P_RE.finditer(html)] + + +def extract_headings(text: str) -> list[str]: + return [m.group(3) for m in H_RE.finditer(text)] + + +def strip_tags(fragment: str) -> str: + return html.unescape(TAG_RE.sub("", fragment)).strip() + + +def translate_known_labels(text: str) -> str: + result = text + for jp, cn in NAV_LABEL_TRANSLATIONS.items(): + result = result.replace(jp, cn) + return result + + +def clean_term_value(value: str) -> str: + return str(value).split("#", 1)[0].strip() + + +def load_terms(novel_root: Path) -> dict[str, str]: + candidates = [novel_root / "mingcibiao.json", novel_root.parent / "mingcibiao.json"] + term_path = next((path for path in candidates if path.exists()), None) + if term_path is None: + return {} + raw = term_path.read_text(encoding="utf-8-sig") + raw = re.sub(r",\s*([}\]])", r"\1", raw) + return {str(key): clean_term_value(value) for key, value in json.loads(raw).items()} + + +def split_ruby_term(value: str) -> tuple[str, str] | None: + match = re.fullmatch(r"(.+?)\(([^()<>]+)\)", clean_term_value(value)) + if not match: + return None + rb, rt = match.group(1).strip(), match.group(2).strip() + if not rb or not rt: + return None + return rb, rt + + +def source_ruby_items(fragment: str) -> list[tuple[str, str]]: + items = [] + for ruby in RUBY_RE.findall(fragment): + rb_parts = [strip_tags(part) for part in RB_RE.findall(ruby)] + rt_parts = [strip_tags(part) for part in RT_RE.findall(ruby)] + rb = "".join(rb_parts) or strip_tags(re.sub(r"]*>.*?", "", ruby, flags=re.S | re.I)) + rt = "/".join(part for part in rt_parts if part) + if rb and rt: + items.append((rb, rt)) + return items + + +def translated_ruby_for(rb: str, rt: str, terms: dict[str, str]) -> tuple[str, str] | None: + for key in (f"{rb}({rt})", rb, rt): + value = terms.get(key) + if not value: + continue + parsed = split_ruby_term(value) + if parsed: + return parsed + return None + + +def restore_translated_ruby(source_inner: str, translated_inner: str, terms: dict[str, str]) -> str: + if not terms: + return translated_inner + restored = translated_inner + for rb, rt in source_ruby_items(source_inner): + parsed = translated_ruby_for(rb, rt, terms) + if not parsed: + continue + cn_rb, cn_rt = parsed + literal = f"{cn_rb}({cn_rt})" + if literal not in restored: + continue + ruby_html = f"{html.escape(cn_rb)}{html.escape(cn_rt)}" + restored = restored.replace(literal, ruby_html, 1) + return restored + + +def set_doc_lang_and_title(text: str, title: str, lang: str = "zh-CN") -> str: + text = re.sub(r".*?", f"{title}", text, count=1, flags=re.S) + text = re.sub(r'xml:lang="[^"]+"', f'xml:lang="{lang}"', text, count=1) + if 'lang="' in text: + text = re.sub(r'lang="[^"]+"', f'lang="{lang}"', text, count=1) + else: + text = text.replace(' str: + if re.search(r"\bstyle\s*=", attrs, re.I): + if re.search(r'style="[^"]*"', attrs, re.I): + def repl(m: re.Match[str]) -> str: + body = m.group(1) + body = re.sub(r"\bcolor\s*:\s*[^;]+;?", "", body, flags=re.I).strip() + if body and not body.endswith(";"): + body += ";" + body = (body + " color:#808080;").strip() + return f'style="{body}"' + + return re.sub(r'style="([^"]*)"', repl, attrs, count=1, flags=re.I) + if re.search(r"style='[^']*'", attrs, re.I): + def repl2(m: re.Match[str]) -> str: + body = m.group(1) + body = re.sub(r"\bcolor\s*:\s*[^;]+;?", "", body, flags=re.I).strip() + if body and not body.endswith(";"): + body += ";" + body = (body + " color:#808080;").strip() + return f"style='{body}'" + + return re.sub(r"style='([^']*)'", repl2, attrs, count=1, flags=re.I) + return attrs + ' style="color:#808080;"' + + +def transform_xhtml( + source_text: str, + translated_text: str | None, + bilingual: bool, + title: str, + terms: dict[str, str], +) -> str: + source_text = set_doc_lang_and_title(source_text, title) + translated_headings = extract_headings(translated_text or "") + if translated_headings: + heading_iter = iter(translated_headings) + + def replace_heading(match: re.Match[str]) -> str: + try: + inner = next(heading_iter) + except StopIteration: + inner = translate_known_labels(match.group(3)) + return f"{inner}" + + source_text = H_RE.sub(replace_heading, source_text) + else: + source_text = translate_known_labels(source_text) + translated_paragraphs = extract_paragraphs(translated_text or "") + matches = list(P_RE.finditer(source_text)) + + pieces: list[str] = [] + last = 0 + for index, match in enumerate(matches): + pieces.append(source_text[last:match.start()]) + attrs = match.group(1) + source_inner = match.group(2) + translated_inner = translated_paragraphs[index] if index < len(translated_paragraphs) else source_inner + translated_inner = restore_translated_ruby(source_inner, translated_inner, terms) + + if bilingual: + gray_attrs = add_gray_style(attrs) + pieces.append(f"{source_inner}

") + if strip_tags(translated_inner): + pieces.append(f"{translated_inner}

") + else: + inner = translated_inner if translated_inner.strip() else source_inner + pieces.append(f"{inner}

") + + last = match.end() + + pieces.append(source_text[last:]) + return "".join(pieces) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build translated and bilingual EPUB variants for any novel volume.") + parser.add_argument( + "--mode", + choices=("translated", "bilingual", "both"), + default="both", + help="Which deliverable(s) to build.", + ) + parser.add_argument( + "--source-epub", + default=os.environ.get("SOURCE_EPUB"), + help="Path to the source EPUB.", + ) + parser.add_argument( + "--novel-root", + default=os.environ.get("NOVEL_ROOT"), + help="Path to the novel project root.", + ) + parser.add_argument( + "--out-dir", + default=os.environ.get("OUT_DIR"), + help="Directory where deliverables are written.", + ) + parser.add_argument( + "--title", + default=os.environ.get("EPUB_TITLE"), + help="Optional display title. If omitted, the source EPUB title is used.", + ) + return parser.parse_args() + + +def load_manifest(novel_root: Path) -> dict: + manifest_path = novel_root / "text" / "_epub_import_manifest.json" + if manifest_path.exists(): + return json.loads(manifest_path.read_text(encoding="utf-8")) + final_dir = novel_root / "final_text" + files = [ + { + "source_epub_path": f"text/{path.name}", + "output": path.name, + } + for path in sorted(final_dir.glob("part*.html")) + ] + return { + "metadata": {"title": novel_root.name}, + "files": files, + } + + +def output_title(manifest: dict, override: str | None) -> str: + return override or manifest["metadata"]["title"] + + +def output_filename(title: str, suffix: str) -> str: + safe_title = re.sub(r'[\\/:*?"<>|]+', "_", title).strip() + return f"{safe_title}_{suffix}.epub" + + +def main() -> int: + args = parse_args() + if not args.source_epub or not args.novel_root: + raise SystemExit("SOURCE_EPUB and NOVEL_ROOT must be provided via args or environment.") + + source_epub = Path(args.source_epub) + novel_root = Path(args.novel_root) + output_dir = Path(args.out_dir) if args.out_dir else novel_root / "deliverables" + output_dir.mkdir(parents=True, exist_ok=True) + + manifest = load_manifest(novel_root) + terms = load_terms(novel_root) + book_title = output_title(manifest, args.title) + source_to_output = {item["source_epub_path"]: item["output"] for item in manifest["files"]} + source_output_to_xhtml = {item["output"]: item["source_epub_path"] for item in manifest["files"]} + + final_dir = novel_root / "final_text" + translated_by_output = {p.name: p for p in final_dir.glob("part*.html")} + + variants = [ + { + "name": output_filename(book_title, "简中译本"), + "title": f"{book_title}(简中译本)", + "bilingual": False, + "mode": "translated", + }, + { + "name": output_filename(book_title, "中日双语版"), + "title": f"{book_title}(中日双语版)", + "bilingual": True, + "mode": "bilingual", + }, + ] + if args.mode != "both": + variants = [item for item in variants if item["mode"] == args.mode] + + with zipfile.ZipFile(source_epub, "r") as src_zip: + entries = src_zip.infolist() + source_bytes = {entry.filename: src_zip.read(entry.filename) for entry in entries} + + for variant in variants: + out_path = output_dir / variant["name"] + if out_path.exists(): + out_path.unlink() + + with zipfile.ZipFile(out_path, "w") as out_zip: + out_zip.writestr("mimetype", b"application/epub+zip", compress_type=zipfile.ZIP_STORED) + for entry in entries: + name = entry.filename + if name == "mimetype": + continue + + data = source_bytes[name] + if name.lower().endswith(".opf"): + text = data.decode("utf-8") + text = re.sub( + r".*?", + f"{variant['title']}", + text, + count=1, + flags=re.S, + ) + text = re.sub( + r".*?", + "zh-CN", + text, + count=1, + flags=re.S, + ) + text = re.sub( + r'', + '', + text, + count=1, + ) + text = re.sub( + r'page-progression-direction="rtl"', + 'page-progression-direction="ltr"', + text, + count=1, + ) + data = text.encode("utf-8") + elif name.lower().endswith(".ncx"): + text = data.decode("utf-8") + text = re.sub(r'xml:lang="[^"]+"', 'xml:lang="zh-CN"', text, count=1) + text = re.sub( + r"\s*.*?\s*", + f"{variant['title']}", + text, + count=1, + flags=re.S, + ) + text = translate_known_labels(text) + data = text.encode("utf-8") + elif ( + (name.startswith("item/xhtml/") and name.endswith(".xhtml")) + or name in source_to_output + ): + manifest_source = source_output_to_xhtml.get("part" + name[len("item/xhtml/p-") : -len(".xhtml")] + ".html") + output_name = source_to_output.get(name) if name in source_to_output else None + if output_name is None and manifest_source is not None: + output_name = source_to_output.get(manifest_source) + if output_name: + translated_file = translated_by_output.get(output_name) + if translated_file and translated_file.exists(): + source_text = data.decode("utf-8") + translated_text = translated_file.read_text(encoding="utf-8") + data = transform_xhtml( + source_text, + translated_text, + variant["bilingual"], + variant["title"], + terms, + ).encode("utf-8") + + compress_type = zipfile.ZIP_STORED if name == "mimetype" else zipfile.ZIP_DEFLATED + out_zip.writestr(name, data, compress_type=compress_type) + + print(f"Wrote {out_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/epub_delivery/epub_delivery_recipe.md b/epub_delivery/epub_delivery_recipe.md new file mode 100644 index 0000000..4724a5e --- /dev/null +++ b/epub_delivery/epub_delivery_recipe.md @@ -0,0 +1,68 @@ +# EPUB 交付说明模板 + +这是每一本新开卷都可以复用的交付说明模板。使用时只需要替换其中的源 EPUB 路径、小说根目录,必要时再覆盖显示标题即可。 + +## 适用范围 + +- 适用于单卷 EPUB 的终交付封装 +- 适用于纯译版和中日双语版两种输出 +- 适用于当前卷和未来新卷 + +## 前置条件 + +1. 该卷的正文翻译已经完成。 +2. `final_text/` 中已经存在修复后的最终 HTML。 +3. 源 EPUB 文件可用。 +4. 小说工程根目录可用。 +5. 如果希望输出文件名使用自定义卷名,可以额外传入 `--title`。 + +## 通用命令 + +### 同时生成纯译版和双语版 + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:SOURCE_EPUB='<源EPUB完整路径>' +$env:NOVEL_ROOT='<小说工程根目录完整路径>' +python '<封装脚本完整路径>' --mode both +``` + +### 仅生成纯译版 + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:SOURCE_EPUB='<源EPUB完整路径>' +$env:NOVEL_ROOT='<小说工程根目录完整路径>' +python '<封装脚本完整路径>' --mode translated +``` + +### 仅生成双语版 + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:SOURCE_EPUB='<源EPUB完整路径>' +$env:NOVEL_ROOT='<小说工程根目录完整路径>' +python '<封装脚本完整路径>' --mode bilingual +``` + +### 指定显示标题 + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:SOURCE_EPUB='<源EPUB完整路径>' +$env:NOVEL_ROOT='<小说工程根目录完整路径>' +python '<封装脚本完整路径>' --title '<显示标题>' --mode both +``` + +## 输出约定 + +- 纯译版 EPUB:仅保留中文译文 +- 双语版 EPUB:原文显示为灰色,译文显示为正常颜色 +- 输出目录默认写入 `NOVEL_ROOT\deliverables` +- 如果传入 `--title`,输出文件名和书内标题都会使用该标题 + +## 生成规则 + +- `生成双语版`:执行双语版输出 +- `生成翻译版`:执行纯译版输出 +- `终交付封装` 或 `合并为epub交付给我`:执行两种输出 diff --git a/rules/translation_rules.md b/rules/translation_rules.md new file mode 100644 index 0000000..de5af3b --- /dev/null +++ b/rules/translation_rules.md @@ -0,0 +1,199 @@ +# 通用翻译规则 + +这是一份给多线程轻小说翻译任务共用的规则文件。所有线程在开始工作前,都应先读取本文件,再读取当前系列的风格文件与术语表。 + +## 适用范围 + +- 适用于本工作流根目录下的所有系列与卷。 +- 适用于单线程翻译,也适用于拆分成多个线程的并行翻译、复审、封装任务。 +- 若某个系列有自己的 `series-style.md`,则以该系列风格文件为准,但不得覆盖本文件的硬性规则。 + +## 标准目录 + +```text + + \ + mingcibiao.json + mingcibiao.ai.json + mingcibiao.candidates.json + series-style.md + \ + text\ + preprocess_text\ + trans_text\ + final_text\ + deliverables\ +``` + +## 多 Agent 协作规则 + +- 默认按“统筹、术语、首译、复审、格式恢复、QA、交付”分工;小任务可由一个 Agent 连续承担多个角色,但仍必须按阶段产出。 +- 每个 Agent 开工前必须确认当前系列、当前卷、输入路径、输出路径、chunk 范围和所读规则文件。 +- 每个线程只处理明确分配的卷、文件或 chunk 范围,不要跨卷混写。 +- 线程之间共享同一套系列术语表和系列风格文件。 +- 多线程任务中,所有线程都必须遵守相同的译名、标点、ruby、标签和交付规则。 +- 首译 Agent 不修改正式术语表;复审 Agent 不直接改源文;QA Agent 默认只报告,除非用户要求修复。 +- 并行 chunk 必须以段落编号作为边界,不得合并、拆分或重排段落。 +- 任何影响全卷一致性的判断,只能进入候选词、风格建议或 QA 报告,不能在单个 chunk 内私自固定。 +- 如果用户要求“继续”,且当前任务没有需要确认的风险点,可以直接接着做下一步。 +- 如果用户要求“自动输入继续”,则在任务完成并等待后续指令时,默认继续推进下一步,直到遇到必须确认的分歧或风险。 + +## 术语规则 + +- `mingcibiao.json` 是正式术语表,优先级最高。 +- `mingcibiao.candidates.json` 只放候选词,不直接当正式词表使用。 +- 用户确认后,先合并候选词,再进入锁词、翻译、复审流程。 +- 术语一旦锁定,所有线程都必须一致使用。 +- 若发现新术语且会影响一致性,先记录为候选词,不要在线程里擅自发明多个译名。 + +### 术语命中规则 + +术语命中必须以当前任务范围实时计算,不能把完整术语表塞进提示词。单段重翻只使用目标段落源文、ruby 与少量上下文中实际命中的术语;复审可额外参考当前译文中的旧译名或疑似不一致译名进行纠偏。 + +命中优先级: + +1. `mingcibiao.json` 的精确源语 key 命中。 +2. 原文 ruby 中的 rb/rt 命中,尤其是技能名、称号、别名、外语读音和世界观术语。 +3. 正文中片假名、专名、组织名、地名、人名、带特殊标记的称谓命中。 +4. 当前译文中已出现的旧译名或疑似不一致译名,仅用于复审纠偏,不用于单段重翻扩展术语。 +5. `mingcibiao.candidates.json` 只作为候选参考,不能覆盖正式术语。 + +这里的“命中”指目标文本与正式术语表 key、别名或 ruby 信息匹配。原文中出现但尚未进入正式术语表的片假名、专名或称谓,只能作为候选术语发现,不得作为固定译名硬约束写入 prompt。当前译文中的旧译名只用于发现不一致或疑似误译,不得反向触发源文未命中的术语提示。 + +术语应用规则: + +- 正式术语表命中后,译文必须使用指定译名;除非上下文证明不是同一概念,才可标记为冲突并暂停确认。 +- value 中如含 `#` 备注,`#` 前作为译名,`#` 后作为说明,不得把说明直接写入正文。 +- 如果术语表写作 `中文(外语/读音)`,且原文对应有意义 ruby,最终 HTML 应恢复为真实 ruby,不保留括号形式。 +- 同一源语在同卷中不得出现多个译名;发现历史不一致时,复审阶段统一并记录。 +- 新术语只进入候选词表,候选项至少包含:源语、建议译名、出现位置、上下文、理由、是否含 ruby。 + +## 系列风格规则 + +- 每个系列都应拥有自己的 `series-style.md`。 +- 在开始首译之前确认 `series-style.md` 已存在且已填写完毕。 +- 如果当前系列没有 `series-style.md`,先在系列根目录生成一个,再要求填写该系列的风格内容后继续后续步骤。 +- 如果 `series-style.md` 为空、只有部分内容,或明显未完成,先提示用户确认是否继续。 +- 如果用户确认继续,则只以已填写的部分作为当前系列的风格约束;空白部分视为未指定,不要自行补写成固定规则。 +- 如果用户不确认继续,先补全系列风格,再进入候选词表或首译。 +- 系列风格文件应记录该系列的语气、叙述习惯、对话风格、术语偏好、标点偏好、双语版处理偏好和禁止事项。 + +## 翻译流程 + +### 1. 导入与预处理 + +- 源文件若为 EPUB,先导入到对应系列与卷目录。 +- 预处理时保留段落编号、div 结构、ruby 信息和必要的标签。 +- 只对正文文本做翻译,不要破坏 HTML 结构。 + +### 2. 候选词表 + +- 先整理候选词表。 +- 用户确认后再合并成正式术语表。 +- 术语表更新后,再进行锁词。 + +### 3. 首译 + +- 首译前确认 `series-style.md` 已填写并与当前系列一致。 +- 如果不存在,先创建 `series-style.md`,写入系列风格模板,再暂停等待用户填写并确认。 +- 如果 `series-style.md` 为空或只有部分内容,先提示用户确认。 +- 用户确认继续后,只按已填写部分限定首译风格;空白项不额外施加约束。 +- 如果用户不确认继续,先补齐系列风格,再开始首译。 +- 只翻译锁词后的 chunk,不要回头直接改源 HTML。 +- 每个段落都要按中文自然语序重写,不要机械贴着日语句序。 +- 保留 `[Pxxxx]`、`...`、必要的 ruby 和 div 标记。 + +### 4. 复审 + +- 复审必须逐段检查,不可以只抽查。 +- 复审时以“源文 + 首译 + 中文自然度”三者一起判断。 +- 复审记录要完整覆盖全部段落。 + +### 5. 恢复与校验 + +- 复审完成后再恢复 HTML。 +- 最后必须做术语、残留日文、ruby、覆盖率检查。 +- 若有可修复的警报,优先修复后再交付。 + +## 验收标准 + +首译验收: + +- chunk 范围内所有 `[Pxxxx]` 均有对应译文。 +- 段落数量、顺序、编号不变。 +- 正式术语命中项全部使用统一译名。 +- 无明显漏译、整句日文残留、机器直译式语序。 +- 必要 HTML、ruby、`` 标签未被破坏。 + +复审验收: + +- 逐段覆盖,不以抽查代替复审。 +- 每段同时检查源文、首译、术语和中文自然度。 +- 修正误译、漏译、术语不一致、角色口吻漂移、标点不统一、ruby 处理错误。 +- reviewed_chunks 可直接进入 HTML 恢复,不遗留“待确认”正文占位。 + +交付验收: + +- final_text 段落覆盖率为 100%。 +- 残留日文只允许出现在双语日文层、必要 ruby rt 或用户确认保留的位置。 +- HTML 可解析,章节顺序、标题、图片、注释、ruby 和空段落正常。 +- 纯译版与中日双语版按用户要求生成,文件名、版本和输出目录清晰。 +- 如存在未修复风险,必须在交付说明中列出。 + +## 中文风格 + +- 输出必须是自然、流畅的简体中文轻小说文风。 +- 以意义优先,再调整成中文语序。 +- 不要保留日语式前置修饰和拖长句。 +- 不要把“看得懂”的汉字词直接照搬成中文。 +- 对话要符合角色性格,不要统一成同一种口吻。 + +## Ruby 规则 + +- 只在有意义时保留 ``。 +- 纯读音、无额外信息的 ruby,通常直接译成普通中文。 +- 隐含义、别名、外语读音、技能名、称号、世界观术语,应按需要保留为真实 ruby。 +- 不要把有意义的 ruby 改成括号。 +- 不要删除 `` 后再用猜测补回。 +- 如果术语表写作 `中文(外语/读音)`,且原文对应位置是有意义 ruby,最终 HTML 必须恢复为 `中文外语/读音`,不要在正文中保留 `中文(外语/读音)` 括号形式。 + +## 标签与格式 + +- 保留 `[Pxxxx]` 段落编号。 +- 保留 `...` 块的内容一致性。 +- 保留必要的 HTML 结构,不要把标签当成普通文本改写。 +- 交付前检查段落、标题、ruby、空段落和灰字双语格式是否正确。 + +## 双语交付 + +- 用户说“生成双语版”时,默认执行双语封装。 +- 双语版规则: + - 日文保留在上或前一层。 + - 日文用灰色字体显示。 + - 中文译文紧随其后。 + - 原始 HTML 结构尽量保持。 +- 用户说“生成翻译版”时,生成纯译文 EPUB。 +- 用户说“终交付封装”或“合并为epub交付给我”时,默认生成两份: + - 纯译文版 + - 中日双语版 + +## 推荐命令模板 + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:SOURCE_EPUB='' +$env:NOVEL_ROOT='' +python '\epub_delivery\build_epub_variants.py' --mode both +``` + +```powershell +python '\epub_delivery\build_epub_variants.py' --mode translated +python '\epub_delivery\build_epub_variants.py' --mode bilingual +``` + +## 默认响应 + +- 当任务完成并等待下一步时,如果用户已经要求自动续跑,则继续下一步。 +- 如果需要用户确认,先暂停并说明原因。 +- 如果用户只说“继续”,则按当前上下文中的下一步执行,不重新解释整套流程。 +- 每次完成代码、文档或工作流更新后,先完成必要检查,再将当前仓库同步到 `https://git.diaomin.asia/` 对应远端;如果缺少远端地址、凭据或登录授权,先询问用户,不要静默跳过。 diff --git a/templates/continue_message_template.md b/templates/continue_message_template.md new file mode 100644 index 0000000..8bab9f8 --- /dev/null +++ b/templates/continue_message_template.md @@ -0,0 +1,28 @@ +# 继续推进模板 + +把下面这段直接发给我,就可以继续当前卷的工作。 + +```text +继续 + +请按当前阶段自动推进下一步。 +若当前步骤已完成且没有风险点,请直接进入下一步。 +若需要我确认名词表、规则冲突或交付形式,请先暂停说明。 +``` + +## 常用补充 + +```text +继续执行 +继续翻译 +继续审核 +继续封装 +``` + +## 推荐简版 + +```text +继续 +请自动推进到下一步。 +``` + diff --git a/templates/delivery_message_template.md b/templates/delivery_message_template.md new file mode 100644 index 0000000..b274014 --- /dev/null +++ b/templates/delivery_message_template.md @@ -0,0 +1,37 @@ +# 双语版与终交付模板 + +把下面这段直接发给我,就可以进入封装交付。 + +```text +终交付封装 + +请按当前卷的封装规则执行: +1. 生成纯译版 EPUB +2. 生成中日双语版 EPUB +3. 如有需要,先修复影响封装的警报 + +双语版要求: +- 日文保留 +- 日文用灰色字体 +- 中文译文紧随其后 + +如果当前卷已经有固定的封装脚本,请直接复用。 +如果需要重新生成,请先确认输出目录无误。 +``` + +## 常用触发词 + +```text +生成双语版 +生成翻译版 +终交付封装 +合并为epub交付给我 +``` + +## 推荐简版 + +```text +终交付封装 +请生成纯译版和中日双语版 EPUB。 +``` + diff --git a/templates/new_volume_start_template.md b/templates/new_volume_start_template.md new file mode 100644 index 0000000..01915a8 --- /dev/null +++ b/templates/new_volume_start_template.md @@ -0,0 +1,96 @@ +# 新卷启动模板 + +把这份文件当作新卷开工清单。新卷开始前,先确认本卷信息,再按下面的默认流程推进。 + +## 你需要先给出的信息 + +- 书名或 EPUB 文件名 +- 对应系列名 +- 卷名 +- 目标目录 +- 是否先做候选词表 +- 是否要中途停下来让我确认名词表 +- 是否需要双语版、纯译版,或两者都要 + +## 开工前顺序 + +1. 读取通用规则:[translation_rules.md](./translation_rules.md) +2. 读取当前系列的 `series-style.md` +3. 如果 `series-style.md` 不存在,先用 [series_style_template.md](./series_style_template.md) 生成一个,再要求填写 +4. 如果 `series-style.md` 为空、只有部分内容,或明显未完成,先提示确认是否继续 +5. 如果你确认继续,则只按已填写部分限定系列风格;空白部分视为未指定 +6. 确认当前系列目录和卷目录 +7. 确认术语表文件: + - `mingcibiao.json` + - `mingcibiao.ai.json` + - `mingcibiao.candidates.json` +8. 确认本卷目录结构: + - `text` + - `preprocess_text` + - `trans_text` + - `final_text` + - `deliverables` + +## 默认工作流 + +### 1. 仅做词表时 + +- 扫描正文与 ruby。 +- 先整理 `mingcibiao.candidates.json`。 +- 不进入正文翻译。 + +### 2. 用户确认后 + +- 合并候选词。 +- 锁词。 +- 再开始翻译正文 chunk。 + +### 3. 首译与复审 + +- 首译只写 `trans_text/chunks`。 +- 首译前再次确认系列风格是否完整;如果只有部分内容,按已填写部分执行。 +- 复审写 `trans_text/reviewed_chunks`。 +- 复审必须逐段覆盖全部段落。 + +### 4. 恢复与校验 + +- 复审完成后再恢复 HTML。 +- 最后检查 ruby、术语残留、覆盖率、HTML 结构。 + +### 5. 封装交付 + +- 生成纯译版时,使用 `--mode translated`。 +- 生成双语版时,使用 `--mode bilingual`。 +- 两份都要时,使用 `--mode both`。 + +## 你的常用触发词 + +- `开始翻译`:开始正文翻译流程 +- `继续`:继续当前阶段的下一步 +- `生成双语版`:只生成中日双语 EPUB +- `生成翻译版`:只生成纯译版 EPUB +- `终交付封装`:生成两份 EPUB +- `合并为epub交付给我`:生成两份 EPUB + +## 默认约定 + +- 如果我已经完成当前步骤并在等待下一步,而你没有特别叫停,我会按上下文继续推进。 +- 如果涉及名词表或会影响全卷一致性的决策,我会先停下来让你确认。 +- 如果你没有特别说明,我默认: + - 先做候选词表,再翻译正文 + - 最后交付纯译版和双语版 + +## 推荐复制模板 + +你每开一卷时,可以直接发: + +```text +新卷: +书名: +系列: +卷名: +目录: +是否先做候选词表:是 / 否 +是否中途确认名词表:是 / 否 +交付方式:纯译版 / 双语版 / 两者都要 +``` diff --git a/templates/series_style_template.md b/templates/series_style_template.md new file mode 100644 index 0000000..59ad0f9 --- /dev/null +++ b/templates/series_style_template.md @@ -0,0 +1,75 @@ +# 系列风格模板 + +把这份文件复制为当前系列根目录下的 `series-style.md`,并按系列实际情况填写。 + +## 基本信息 + +- 系列名: +- 系列简称: +- 适用卷范围: +- 风格负责人: +- 最近更新时间: + +## 叙述风格 + +- 整体语气: +- 叙述节奏: +- 句子长短偏好: +- 常用视角: +- 叙事温度: + +## 对话风格 + +- 角色口吻差异: +- 礼貌与粗口尺度: +- 幽默、吐槽、卖萌、傲娇等表达偏好: +- 对话标点偏好: + +## 术语与译名 + +- 固定术语偏好: +- 人名、地名、组织名的译法: +- 技能名、称号、武器名的译法: +- 日文保留、音译、意译的优先级: + +## 标点与格式 + +- 句末标点偏好: +- 省略号、破折号、感叹号、问号偏好: +- 书名号、引号、括号使用习惯: +- 双语版灰字处理偏好: + +## Ruby 处理偏好 + +- 何种 ruby 应保留: +- 何种 ruby 可直接转成普通中文: +- 外语读音或隐藏含义的处理方式: + +## 禁止事项 + +- 不希望出现的翻译腔: +- 不希望出现的固定误译: +- 本系列禁止照搬的表达: + +## 参考片段 + +### 原文片段 1 + +- + +### 推荐译法 1 + +- + +### 原文片段 2 + +- + +### 推荐译法 2 + +- + +## 备注 + +- 如果后续又确认了新的固定译法,请及时补充到这里。 +- 本文件是系列级别的风格依据,不要与其他系列混用。 diff --git a/templates/startup_message_template.md b/templates/startup_message_template.md new file mode 100644 index 0000000..6b3e0ec --- /dev/null +++ b/templates/startup_message_template.md @@ -0,0 +1,48 @@ +# 启动消息模板 + +把下面这段直接发给我,就可以作为新卷开工消息。 + +```text +新卷启动 +书名: +系列: +卷名: +源文件: +工作目录: + +先做候选词表:是 / 否 +名词表确认后再开始正文:是 / 否 +是否允许我说“继续”后自动推进:是 / 否 +交付方式:纯译版 / 双语版 / 两者都要 + +若有专用规则文件,请先读取: +1. 通用规则:/translation_rules.md +2. 新卷模板:/new_volume_start_template.md + +请先检查目录与术语表,再按当前阶段执行。 +``` + +## 常用追加指令 + +```text +开始翻译 +继续 +生成双语版 +生成翻译版 +终交付封装 +合并为epub交付给我 +``` + +## 推荐简版 + +```text +新卷启动: +书名:<填写> +系列:<填写> +卷名:<填写> +源文件:<填写> +工作目录:<填写> +先做候选词表:是 +名词表确认后再开始正文:是 +交付方式:两者都要 +``` diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md index 5231726..83c7fb9 100644 --- a/tools/epub_review_editor/MAINTENANCE.md +++ b/tools/epub_review_editor/MAINTENANCE.md @@ -2,9 +2,9 @@ ## 目标与边界 -本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md`、`rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。 +本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。它也提供可选的书架入口 AI 翻译模块,用于对已入书架的日语 EPUB 发起 API 辅助首译,并生成中日双语或纯中文 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md`、`rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。 -审校器不负责整卷首译流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。 +AI 翻译模块是可选辅助首译入口,不替代长期项目里的候选词确认、复审、QA 与正式交付流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。 ## 数据模型与会话目录 @@ -14,6 +14,12 @@ epub_review_sessions/ app_state.json gpt_config.json + translation_jobs/ + / + job.json + translations.json + translated_outputs/ + *_AI翻译_*.epub / source.epub extracted/ @@ -36,6 +42,9 @@ epub_review_sessions/ - `session.json` 与 `app_state.json` 只保存会话定位信息。 - `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。 - `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。 +- `translation_jobs//job.json` 保存整书 AI 翻译任务状态、进度、输出路径和日志;不得保存 API Key。 +- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。 +- 项目根目录的 `打开EPUB审校器.cmd` 是用户一键入口,委托 `tools/epub_review_editor/open_editor.ps1` 创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本已运行服务或启动 `server.py --daemon` 并打开浏览器;该入口不得硬编码用户私有路径。 ## 目录与插图 @@ -60,12 +69,19 @@ epub_review_sessions/ 验收口径: +- 顶栏左上角必须保留“书架”入口;书架是独立页面,列出 `--review-root` 下历史打开过的审校书籍/会话。 +- 点击书架条目必须通过会话打开流程进入对应审校页面,并恢复该书上次阅读位置;从审校页进入书架或切换书籍前必须保存当前阅读位置并沿用未保存编辑保护。 +- 没有历史书籍时,书架显示空状态并提供“打开新的 EPUB”入口;开始页/书架页顶栏保持正常显示,不套用审校页沉浸隐藏。 - 初始进入审校时,左侧侧栏隐藏,正文占主要空间。 - 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。 - 目录可展开/收起章节,插图作为所属章节的次级目录条目可点击阅读,文字 part 可点击定位。 - 点击目录章节、part 或插图条目后,侧栏不得自动收起;用户仍可点“隐藏”或按 `Escape` 关闭。 - 检索必须保留“当前范围 / 全书全局”切换;当前范围随当前章节或 part 更新,全局检索可跨章节返回并跳转。 - 阅读模式不显示每段编号、快速编辑、快速标记按钮。 +- 阅读区提供默认、护眼、夜间背景切换,并允许调整字号与行距;设置可保存在浏览器本地,不要求写入 EPUB 或 session。 +- 审校页顶部标题栏与阅读标题栏默认收起,鼠标移到页面上方或键盘焦点进入标题栏时再显示;开始页保持正常显示;展开后不得遮住侧栏“隐藏”或审校工具“关闭”等面板操作。 +- 鼠标停留在顶栏或阅读标题栏时,沉浸顶栏不得因自动计时过早收起;前端脚本和样式发布时应带版本参数或等效缓存失效机制,避免浏览器继续执行旧入口代码。 +- 阅读区“上一节 / 下一节”必须按 spine/目录内的小章节顺序移动,覆盖文字 part 与插图条目,不得只跳顶层大章节。 - 点击正文段落打开右侧精修与重翻面板。 - 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。 - 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。 @@ -98,9 +114,10 @@ GPT 能力是可选增强,不应影响离线审校。 - API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`。 - `/api/gpt/config` 不得返回 API Key。 - `/api/gpt/config` 可以返回提示词与预设,但不得返回密钥。 -- `/api/glossary` 读取和保存配置路径指向的 `mingcibiao.json`;默认路径是项目根目录下当前 TRPG 系列的正式术语表。 +- `/api/glossary` 读取和保存配置路径指向的 `mingcibiao.json`;默认路径应由当前打开 EPUB 对应的系列目录或用户保存的配置决定,不应在通用维护口径中硬编码某个具体系列。 +- 未保存配置时,可在项目根目录下唯一的一级系列目录 `mingcibiao.json` 中自动推断默认术语表;若无法唯一推断,则回退到项目根目录 `mingcibiao.json` 并允许用户在网页中显式设置。 - 术语表保存应保持 JSON 对象结构:key 为源语或匹配词,value 为译名,可继续使用 `#` 作为备注分隔。 -- 重翻时必须实时读取术语表,按目标段落日文、HTML 与当前中文命中相关条目,不得只在提示词里泛泛要求“遵守术语表”。 +- 重翻时必须实时读取术语表,按目标段落日文纯文本、日文 HTML 与 ruby/rt 命中相关条目,不得只在提示词里泛泛要求“遵守术语表”。 - 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。 - 重翻请求不得临时覆盖 `base_url` 或 `model` 后继续复用已保存密钥;要更换端点或模型必须先保存配置,避免把密钥发往未确认端点。 @@ -108,20 +125,74 @@ GPT 能力是可选增强,不应影响离线审校。 1. 用户点击阅读段落,打开右侧面板。 2. 用户可填写额外重翻要求。 -3. `/api/row//retranslate` 使用提示词、目标语句、当前中文、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。 +3. `/api/row//retranslate` 使用提示词、目标语句、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。 4. 候选只显示在面板里,不自动覆盖。 5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。 6. 候选事件写入 jsonl,便于后续根据精修反馈优化提示词;当前版本不自动改写提示词。 +术语实时命中实现口径: + +- 每次 `/api/row//retranslate` 请求都必须读取当前配置的 glossary 文件,不使用服务启动时的缓存作为唯一来源。 +- 单段重翻的术语命中范围只包括目标段落日文纯文本和日文 HTML 中的可见文本/ruby/rb/rt 信息;当前中文译文不能反向触发新术语,避免把旧误译或幻觉带入 prompt。 +- 传给 GPT 的目标日文 HTML 只保留必要内联标签和 ruby 结构,不携带 class/style/data-* 等属性,避免属性值污染术语命中或 prompt。 +- 前后一段上下文只用于语气、指代和连贯性参考,不参与单段重翻的术语命中;单段重翻的 `glossary_matches` 只由目标段落自身触发。 +- 正式术语表 value 如包含 `#`,`#` 前是指定译名,`#` 后只是备注,不写入正文。 +- 未命中术语时,重翻 prompt 不得塞入无关术语兜底规则;例如只有原文出现 `ファルシオン` 或 `Falchion` 时,才可命中“大砍刀”。 +- 读取旧版保存提示词时,若只剩“优先服从给定名词表”这类泛泛规则,应回退到当前内置预设,避免用户继续沿用已淘汰的全局术语设计。 +- 任何术语特例都不得写入全局 prompt。Falchion / ファルシオン / 大砍刀 只能由 `glossary_matches` 动态生成;未命中时,重翻 messages 中不得出现这些字符串。 +- 命中结果必须随候选事件写入 jsonl,字段至少包含 `glossary_path` 与 `glossary_matches`;未命中时记录空数组,便于排查 prompt。 + +Prompt 第一性原理: + +- prompt 必须只携带目标段落、少量上下文、命中术语、系列风格摘要和输出格式;单段重翻不携带完整术语表。 +- prompt 不得只泛泛要求遵守术语表,必须列出本段命中的具体术语;未命中的术语不得进入提示。 +- prompt 必须要求只输出修订后的中文 HTML,不输出说明。 +- prompt 必须要求不合并、不拆分段落,不删除段落编号或必要标签。 +- prompt 必须要求保留有意义 ruby 为真实 `......`。 + Prompt 硬规则: - 输出简体中文轻小说文风。 - 意义优先,不贴日语语序硬译。 - 不合并、不拆分段落。 - 有意义 ruby 必须保留为真实 `......`,不要改成括号。 -- Falchion / ファルシオン 固定译为“大砍刀”,不要译成“偃月刀”。 - 标点统一为简中出版格式:对话引号用「」,省略号用……,破折号用——,问号用?,感叹号用!,问叹连用用?!/!?。 +## 整书 AI 翻译 + +整书 AI 翻译从书架页进入,只处理已经上传或打开过、因此已有 session 的 EPUB。它必须作为后台任务运行,前端只负责创建任务、轮询进度、展示日志和打开输出 session。 + +后端接口口径: + +- `GET /api/translation/defaults` 返回当前公开 GPT 配置、默认提示词、默认术语表路径、默认范围和输出模式,不返回 API Key。 +- `GET /api/session//translation-source` 只读取指定 session,统计可翻译日文段落和样例,不切换 active session。 +- `POST /api/translation/start` 创建后台任务并快速返回 `job_id`;请求不得临时覆盖 `base_url` 或 `model` 后复用已保存密钥,要更换端点或模型必须先保存配置。 +- `GET /api/translation/jobs/` 返回任务状态、进度、日志、输出 EPUB 和输出 session id,不返回 API Key。 + +翻译输入与提示词: + +- 日语源 EPUB 需要新的源段落抽取器,不复用只识别双语灰字的 `build_rows()`。 +- 默认范围必须是前 N 段试译,当前默认 20 段;全书翻译必须由用户显式选择。 +- 每段请求只携带目标段落日文 HTML、前后一段少量上下文、该段实时命中的术语、翻译提示词、格式提示词与角色口吻提示词。 +- 不得把完整术语表塞进 prompt;未命中的术语不得进入 prompt。 +- 输出只接受当前段中文 HTML 片段,随后进行中文标点规整、HTML sanitize 与术语 ruby 括号形式修复。 + +输出与书架: + +- `bilingual` 模式在原日文 `

` 后插入中文 `

`,并把原日文段落标为灰色,方便进入现有双语审校器逐段审校。 +- `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行。 +- 图片、样式、OPF、spine 和非正文文件应原样保留。 +- 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。 +- 输出 EPUB 创建 session 时不应自动切换用户当前 active session;只有用户点击“打开输出审校”才切换。 + +任务安全与恢复: + +- 后台 job 不能在网络调用期间持有全局锁;只在写状态文件时短暂加锁或原子写入。 +- `job.json` 和中间 `translations.json` 必须原子写入,避免前端轮询读到半截 JSON。 +- 日志、状态、EPUB 元数据和反馈文件不得包含 API Key。 +- 任务失败时应将状态置为 `failed` 并写清楚错误,不能让前端永久停在 running。 +- 后续如加入暂停/取消/恢复,必须保持现有 `job.json` 字段向后兼容。 + ## 版本规则 版本号使用 `version = "0.1.0"` 格式: @@ -139,12 +210,21 @@ Prompt 硬规则: - `README.md` 当前版本与版本说明 - 必要时同步 `MAINTENANCE.md` 中的维护口径 +版本文档同步边界: + +- 审校器自身行为、API、配置、前端交互变化,同步本文件。 +- 会影响整个翻译项目的规则变化,同步项目根目录 `AGENTS.md` 与 `rules/translation_rules.md`,不要只写在审校器维护手册中。 +- 对外最小包内容、启动方式或发布说明变化,同步 README。 + ## 回归清单 每轮改动至少检查: - `python -m py_compile tools/epub_review_editor/server.py tools/epub_review_editor/version.py` - `node --check tools/epub_review_editor/static/app.js` +- `powershell -NoProfile -ExecutionPolicy Bypass -File tools/epub_review_editor/open_editor.ps1` 能启动服务并打印 URL +- 从项目根目录执行 `打开EPUB审校器.cmd` 能启动服务;失败时窗口停留并显示错误;成功时浏览器打开书架/上传页 +- 重复执行一键入口时优先复用同版本已运行服务;如需新起服务,端口探测不得允许多个服务同时监听同一端口 - `git diff --check` - `/api/version` - `/api/session` @@ -158,4 +238,21 @@ Prompt 硬规则: - 配置保存后 `/api/gpt/config` 不包含密钥字段 - `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致 - 重翻候选事件包含 `glossary_path` 与 `glossary_matches` +- 无关段落的重翻 messages 不得包含 `大砍刀`、`Falchion` 或 `ファルシオン`;含 `ファルシオン` 或 ruby `Falchion` 的段落必须命中“大砍刀” +- 顶栏复杂操作收敛在“审校工具”入口;未选择段落时仍可配置 AI、术语表并执行反馈与交付,段落编辑/重翻按钮禁用 - `/api/reading-position` 能保存/读取位置;刷新或重启后回到上次阅读处 +- 阅读外观默认/护眼/夜间、字号、行距切换即时生效,刷新后仍保留;夜间模式下正文、原文、按钮和侧栏文本可读 +- 审校页顶部栏和阅读标题栏默认不遮挡正文,鼠标移到页面上方后可操作;开始页顶栏不隐藏;目录侧栏和审校工具打开时,其顶部按钮仍可直接点击 +- 鼠标停留在顶栏按钮区域时,书架/打开 EPUB/审校工具按钮能连续点击,不会在点击前自动收起;浏览器重新加载后应使用当前版本 app.js/style.css +- “上一节 / 下一节”在相邻 part 和章节内插图之间移动,不再按顶层大章节跳转 +- 顶栏左上角“书架”能打开独立书架页;书架列出历史会话,空状态可进入上传页,点击书籍可进入对应审校页并恢复阅读位置 +- 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校” +- `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取 +- `/api/session//translation-source` 能统计日语 EPUB 段落;非日语或无正文时返回清晰空状态 +- 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻 +- 启动整书翻译任务后,`/api/translation/jobs/` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志 +- 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认 +- 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表 +- 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0` +- 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校 +- 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止 diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index 0a1ea68..aad2f5a 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -1,6 +1,6 @@ # 双语 EPUB 审校编辑器 -当前版本:`0.8.0` +当前版本:`0.12.2` 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 @@ -26,12 +26,26 @@ `0.5.0` 改进长篇目录与检索:目录章节标题不再用正文首段猜测,章节内插图作为次级条目挂在所属章节下,阅读时可按原 spine 顺序穿插显示;点击目录不会自动收起侧栏,桌面端正文会为常驻侧栏让位;检索新增“当前范围 / 全书全局”切换。 -`0.6.0` 新增 GPT 提示词设置:可在网页中单独配置“翻译内容提示词”“格式与标点提示词”“角色状态与口吻提示词”,并提供默认预设;单段重翻只携带目标语句、当前译文、相关名词表、少量前后文和角色口吻提示,候选输出会进行中文标点规整与术语 ruby 括号形式修正。 +`0.6.0` 新增 GPT 提示词设置:可在网页中单独配置“翻译内容提示词”“格式与标点提示词”“角色状态与口吻提示词”,并提供默认预设;单段重翻只携带目标语句、相关名词表、少量前后文和角色口吻提示,候选输出会进行中文标点规整与术语 ruby 括号形式修正。 `0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。 `0.8.0` 新增阅读位置恢复:阅读器会自动记录当前章节/part/插图、当前可见段落和滚动位置;重新打开服务、刷新页面或切回已有会话时,会回到上次阅读的位置。 +`0.9.0` 收敛重翻与审校工具入口:顶栏复杂操作统一到“审校工具”;单段重翻只携带目标段落真实命中的术语,移除无关段落里的 Falchion/大砍刀全局注入,并按第一性原理精简 prompt。 + +`0.10.0` 新增阅读外观与沉浸阅读:阅读区可切换默认、护眼、夜间背景,并保存字号与行距;审校页顶部栏和阅读标题栏默认收起,鼠标移到页面上方时再显示;阅读区上一节/下一节按 part 或插图小章节移动。 + +`0.11.0` 新增书架页面:顶栏左上角提供“书架”入口,集中列出历史打开过的审校书籍;点击书籍可直接进入对应审校页面,并继续沿用该书的上次阅读位置。 + +`0.11.1` 修复沉浸顶栏在鼠标停留时过早收起的问题,并给前端静态资源加入版本参数,避免浏览器缓存旧脚本导致新入口不可用。 + +`0.12.0` 新增书架入口的 AI 翻译模块:可从书架选择日语 EPUB,设置 OpenAI 兼容 API、提示词、术语表、翻译范围与输出模式,后台显示翻译进度;完成后生成的中日双语或纯中文 EPUB 会自动加入书架。 + +`0.12.1` 新增一键启动入口:项目根目录提供 `打开EPUB审校器.cmd`,会自动准备本地 Python 环境、安装依赖并以前台浏览器打开书架/上传页。 + +`0.12.2` 修复一键启动入口首次运行时依赖检测会被 Flask 缺失的 traceback 中断的问题;现在会先安静检测依赖,缺失时自动安装,再启动网页工具。重复打开入口时会优先复用同版本已运行服务,并修正端口探测,避免多个服务同时监听同一端口。 + ## 独立安装 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: @@ -50,6 +64,14 @@ python3 -m venv .venv ## 启动 +最方便的方式是在项目根目录双击: + +```text +打开EPUB审校器.cmd +``` + +第一次运行会在 `tools/epub_review_editor/.venv` 创建本地 Python 环境并安装依赖;之后会直接启动服务并打开浏览器。 + 不预先指定 EPUB,直接打开上传界面: ```powershell @@ -83,11 +105,17 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio - 启动时不带 EPUB 路径,会进入开始页。 - 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。 - 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。 +- 顶栏左上角“书架”会列出历史打开过的书籍,适合在多本书之间快速切换;点击书籍后会进入对应审校页面。 +- 书架中每本书都有“翻译”按钮,可对日语 EPUB 发起 AI 翻译任务;输出 EPUB 会自动加入书架。 - 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。 ## 能做什么 - 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。 +- 顶栏左上角可进入“书架”,集中查看历史打开过的书籍、审校进度和最近阅读记录,并跳转回对应审校页面。 +- 阅读区可切换默认、护眼、夜间背景,并调整字号与行距;设置会保存在当前浏览器中,下次打开继续沿用。 +- 审校页顶部标题栏与阅读标题栏默认隐藏,鼠标移到页面上方时显示,减少长文阅读时的遮挡。 +- 阅读区“上一节 / 下一节”按 part 或插图小章节移动,不再只跳顶层大章节。 - 每个审校会话会自动保存上次阅读位置,下次打开会回到上次所在章节、part、插图或段落附近。 - 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。 - 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图。 @@ -95,8 +123,9 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio - 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。 - 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。 - 精修模式保留原有逐段编辑界面,用于较细的逐段复审。 -- 可在网页中配置 OpenAI 兼容的 `base_url`、`model`、API Key 与重翻提示词,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。 -- 可在“提示词与 API”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。 +- 可从“审校工具”统一配置 OpenAI 兼容的 `base_url`、`model`、API Key、重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。 +- 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。 +- 可从书架进入“AI 翻译 EPUB”页面,对日语 EPUB 设置翻译内容提示词、格式与标点提示词、角色口吻提示词、术语表路径、翻译范围、输出为中日双语或纯中文 EPUB;任务在后台运行,页面实时显示进度与日志。 - 直接编辑中文译文。 - 标记问题段落或选中文本。 - 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。 @@ -125,23 +154,29 @@ epub_review_sessions\_\ - `exports/*_网页审校版_*.epub`:导出的修订版 EPUB。 - `review_state/state.json`:网页编辑器当前状态。 - `extracted/`:EPUB 解包后的工作副本。 +- `translation_jobs//job.json`:AI 翻译任务状态、进度和日志。 +- `translated_outputs/*_AI翻译_*.epub`:AI 翻译模块生成并自动加入书架的 EPUB。 在服务器上使用时,“生成反馈”和“导出审校 EPUB”会返回浏览器可访问的下载地址,不需要登录服务器找文件。 ## 使用建议 - 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。 +- 同时审校多本书时,优先从顶栏左上角进入“书架”,再选择目标书籍;从书架进入会恢复该书上次阅读位置。 - 需要集中逐段复审时切到“精修模式”,左侧段落列表会跟随当前章节筛选。 - 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。 - 想直接修正译文时:编辑中文译文后保存本段。 -- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“Falchion 固定译为大砍刀,不要译成偃月刀”。 -- 想单段重翻时:点击段落打开右侧面板,先在“提示词与 API”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径默认指向本项目的 `TRPG玩家在异世界打造最强角色/mingcibiao.json`,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。 -- 想调整术语时:展开“提示词与 API”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。 +- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“某个术语只在原文实际出现时才进入重翻提示”。 +- 想单段重翻时:点击段落打开“审校工具”,先在“AI 设置与术语表”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。 +- 想整书 AI 翻译时:先上传或打开日语 EPUB,让它出现在书架里;在书架卡片点击“翻译”,保存 API 设置与术语表路径,默认先做前 20 段试译。确认效果后再把范围切换为全书。中日双语输出可直接进入审校;纯中文输出会入书架,但没有逐段双语审校行。 +- 术语表路径默认会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断;如果无法唯一推断,请在页面里填写目标术语表路径。 +- 想调整术语时:打开“审校工具”的“AI 设置与术语表”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。 - 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md` 和 `translation_feedback.jsonl`。 ## 当前限制 - 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。 +- AI 翻译模块是可选辅助首译入口,不替代正式长卷的候选词确认、复审和 QA;默认试译前 20 段,避免误触发全书 API 成本。 - 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。 - GPT API Key、提示词与术语表路径仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。术语表编辑会直接写入配置路径指向的 JSON 文件,服务器部署时请保护好审校目录和术语表,不要把没有鉴权的服务暴露到公网。 - 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。 diff --git a/tools/epub_review_editor/open_editor.ps1 b/tools/epub_review_editor/open_editor.ps1 new file mode 100644 index 0000000..a713b4c --- /dev/null +++ b/tools/epub_review_editor/open_editor.ps1 @@ -0,0 +1,134 @@ +$ErrorActionPreference = "Stop" + +$toolRoot = Split-Path -Parent $PSCommandPath +$projectRoot = (Resolve-Path (Join-Path $toolRoot "..\..")).Path +$serverPath = Join-Path $toolRoot "server.py" +$reviewRoot = Join-Path $projectRoot "epub_review_sessions" +$venvRoot = Join-Path $toolRoot ".venv" +$venvPython = Join-Path $venvRoot "Scripts\python.exe" +$requirements = Join-Path $toolRoot "requirements.txt" +$versionFile = Join-Path $toolRoot "version.py" +$defaultPort = 5177 + +$env:PYTHONIOENCODING = "utf-8" + +function Get-PythonCommand { + $pyLauncher = Get-Command py -ErrorAction SilentlyContinue + if ($pyLauncher) { + return [pscustomobject]@{ Exe = $pyLauncher.Source; Args = @("-3") } + } + + $python = Get-Command python -ErrorAction SilentlyContinue + if ($python) { + return [pscustomobject]@{ Exe = $python.Source; Args = @() } + } + + throw "Python was not found. Install Python 3, then run this launcher again." +} + +function Invoke-PythonCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Exe, + [string[]]$Args = @() + ) + + & $Exe @Args + return $LASTEXITCODE +} + +function Test-FlaskInstalled { + & $venvPython -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('flask') else 1)" 2>$null + return ($LASTEXITCODE -eq 0) +} + +function Get-ExpectedVersion { + if (!(Test-Path $versionFile)) { + return "" + } + $content = Get-Content -Raw -Encoding UTF8 $versionFile + $match = [regex]::Match($content, 'version\s*=\s*"([^"]+)"') + if ($match.Success) { + return $match.Groups[1].Value + } + return "" +} + +function Test-LocalPortOpen { + param( + [Parameter(Mandatory = $true)] + [int]$Port + ) + + $client = New-Object System.Net.Sockets.TcpClient + try { + $async = $client.BeginConnect("127.0.0.1", $Port, $null, $null) + if (!$async.AsyncWaitHandle.WaitOne(100, $false)) { + return $false + } + $client.EndConnect($async) + return $true + } catch { + return $false + } finally { + $client.Close() + } +} + +function Get-RunningEditorUrl { + $expectedVersion = Get-ExpectedVersion + for ($port = $defaultPort; $port -lt ($defaultPort + 100); $port++) { + if (!(Test-LocalPortOpen -Port $port)) { + continue + } + $url = "http://127.0.0.1:$port" + try { + $versionPayload = Invoke-RestMethod -Uri "$url/api/version" -TimeoutSec 1 -ErrorAction Stop + if (!$expectedVersion -or $versionPayload.version -eq $expectedVersion) { + return "http://localhost:$port" + } + } catch { + continue + } + } + return "" +} + +$runningUrl = Get-RunningEditorUrl +if ($runningUrl) { + Write-Host "EPUB review editor is already running." + Start-Process $runningUrl + Write-Host $runningUrl + Write-Host "review root: $reviewRoot" + exit 0 +} + +if (!(Test-Path $venvPython)) { + Write-Host "Creating local Python environment..." + $python = Get-PythonCommand + $venvArgs = @() + if ($python.Args) { + $venvArgs += $python.Args + } + $venvArgs += @("-m", "venv", $venvRoot) + if ((Invoke-PythonCommand -Exe $python.Exe -Args $venvArgs) -ne 0) { + throw "Failed to create local Python environment." + } +} + +if (!(Test-FlaskInstalled)) { + Write-Host "Installing review editor dependencies..." + & $venvPython -m pip install -r $requirements + if ($LASTEXITCODE -ne 0) { + throw "Failed to install dependencies from requirements.txt." + } + if (!(Test-FlaskInstalled)) { + throw "Dependencies were installed, but Flask is still unavailable in the local Python environment." + } +} + +Write-Host "Starting EPUB review editor..." +& $venvPython $serverPath --review-root $reviewRoot --daemon +if ($LASTEXITCODE -ne 0) { + throw "Failed to start EPUB review editor." +} diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index aa2e4e6..af1c17c 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -36,7 +36,7 @@ except ImportError: "MINOR": "新增向后兼容能力、API 字段或可选配置", "MAJOR": "破坏兼容的 API、配置或行为变更", } - version = "0.8.0" + version = "0.12.2" P_RE = re.compile(r"(]*>)(.*?)(

)", re.S | re.I) @@ -59,9 +59,15 @@ UPLOAD_DIR_NAME = "uploads" DEFAULT_GPT_BASE_URL = "https://api.openai.com/v1" DEFAULT_GPT_MODEL = "gpt-4o-mini" DEFAULT_TRANSLATION_PROMPT = ( + "你是日语轻小说到简体中文的审校重翻助手。只重翻当前目标语句/段落。" + "目标是自然、流畅、忠实的简体中文轻小说文风。意义优先,再调整为中文语序;" + "不要贴着日语句序硬译,也不要补写原文没有的信息。" + "术语、人名、地名、TRPG/系统词只在本段命中时服从给定术语;没有命中的术语不要写进译文。" +) +LEGACY_TRANSLATION_PROMPT_WITHOUT_GLOBAL_TERM = ( "你是日语轻小说到简体中文的审校重翻助手。目标是把当前目标语句译成自然、流畅、忠实的简体中文轻小说文风。" "意义优先,再调整为中文语序;不要贴着日语句序硬译,也不要补写原文没有的信息。" - "术语、人名、地名、TRPG/系统词优先服从给定名词表;Falchion / ファルシオン固定译为“大砍刀”,绝对不要译成“偃月刀”。" + "术语、人名、地名、TRPG/系统词优先服从给定名词表。" ) DEFAULT_FORMAT_PROMPT = ( "只输出目标语句的新中文译文 HTML 片段,不要解释、不要 Markdown、不要代码围栏。" @@ -86,6 +92,13 @@ GPT_PROMPT_MAX_CHARS = { "character_prompt": 3000, } GLOSSARY_ENTRY_LIMIT = 80 +GLOSSARY_PROMPT_LIMIT = 16 +TRANSLATION_JOB_DIR_NAME = "translation_jobs" +TRANSLATION_OUTPUT_DIR_NAME = "translated_outputs" +TRANSLATION_LOG_LIMIT = 80 +DEFAULT_TRANSLATION_LIMIT = 20 +TRANSLATION_RANGE_LIMIT_MAX = 5000 +TRANSLATION_CONTEXT_RADIUS = 1 def now_iso() -> str: @@ -99,11 +112,20 @@ def safe_slug(value: str, max_len: int = 80) -> str: def find_free_port(start: int, host: str = "127.0.0.1") -> int: + connect_host = "127.0.0.1" if host in {"localhost", "0.0.0.0", "::"} else host + bind_host = "127.0.0.1" if host in {"localhost", "::"} else host + port_in_use = getattr(socket, "SO_EXCLUSIVEADDRUSE", None) for port in range(start, start + 100): + try: + with socket.create_connection((connect_host, port), timeout=0.2): + continue + except OSError: + pass with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if port_in_use is not None: + sock.setsockopt(socket.SOL_SOCKET, port_in_use, 1) try: - sock.bind((host, port)) + sock.bind((bind_host, port)) except OSError: continue return port @@ -125,6 +147,13 @@ def write_json(path: Path, data: Any) -> None: path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +def write_json_atomic(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f"{path.name}.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp.replace(path) + + def append_jsonl(path: Path, record: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("a", encoding="utf-8", newline="\n") as fh: @@ -136,6 +165,10 @@ def strip_tags(fragment: str) -> str: return html.unescape(TAG_RE.sub("", fragment)).strip() +def strip_html_keep_ruby_text(fragment: str) -> str: + return html.unescape(TAG_RE.sub("", fragment or "")).strip() + + def compact_text(value: str) -> str: return re.sub(r"\s+", " ", html.unescape(value or "")).strip() @@ -294,6 +327,8 @@ def normalize_gpt_prompt(name: str, value: Any) -> str: text = str(value or "").strip() if not text: text = GPT_PROMPT_DEFAULTS[name] + if name == "translation_prompt": + text = normalize_translation_prompt(text) return text[: GPT_PROMPT_MAX_CHARS[name]] @@ -301,12 +336,58 @@ def gpt_prompt_defaults() -> dict[str, str]: return dict(GPT_PROMPT_DEFAULTS) +def compact_prompt_text(text: str) -> str: + return re.sub(r"\s+", "", text or "") + + +def normalize_translation_prompt(text: str) -> str: + text = strip_obsolete_global_term_rules(text) + if not text: + return DEFAULT_TRANSLATION_PROMPT + if compact_prompt_text(text) == compact_prompt_text(LEGACY_TRANSLATION_PROMPT_WITHOUT_GLOBAL_TERM): + return DEFAULT_TRANSLATION_PROMPT + scoped_clause = "术语、人名、地名、TRPG/系统词只在本段命中时服从给定术语;没有命中的术语不要写进译文。" + text = re.sub( + r"术语、人名、地名、TRPG/系统词优先服从给定名词表[。;;]?", + scoped_clause, + text, + ) + return text + + +def strip_obsolete_global_term_rules(text: str) -> str: + """Old saved prompts hard-coded one term into every request; keep terms scoped.""" + parts = re.split(r"([。!?\n;;]+)", text) + kept: list[str] = [] + for idx in range(0, len(parts), 2): + segment = parts[idx] + delimiter = parts[idx + 1] if idx + 1 < len(parts) else "" + has_falchion = re.search(r"Falchion|ファルシオン", segment, flags=re.I) + has_translation = "大砍刀" in segment or "偃月刀" in segment + if has_falchion and has_translation: + continue + kept.append(segment + delimiter) + text = "".join(kept) + text = re.sub(r"[;;]\s*([。!?])", r"\1", text) + text = re.sub(r"[;;]\s*$", "。", text) + text = re.sub(r"。{2,}", "。", text) + return text.strip() + + def project_root() -> Path: return Path(__file__).resolve().parents[2] def default_glossary_path() -> Path: - return project_root() / "TRPG玩家在异世界打造最强角色" / "mingcibiao.json" + root = project_root() + candidates = sorted( + path + for path in root.glob("*/mingcibiao.json") + if "epub_review_sessions" not in path.parts + ) + if len(candidates) == 1: + return candidates[0] + return root / "mingcibiao.json" def normalize_glossary_path_value(value: str = "") -> str: @@ -475,16 +556,58 @@ def search_glossary_entries(glossary: dict[str, str], term: str, limit: int = GL return selected -def glossary_matches_for_prompt(review_root: Path, row: dict[str, Any], limit: int = GLOSSARY_ENTRY_LIMIT) -> list[str]: +def parenthetical_aliases(value: str) -> list[str]: + aliases: list[str] = [] + for match in re.finditer(r"\(([^()]{1,80})\)|(([^()]{1,80}))", value): + alias = (match.group(1) or match.group(2) or "").strip() + if alias: + aliases.append(alias) + return aliases + + +def strip_parenthetical_aliases(value: str) -> str: + return re.sub(r"\([^()]{1,80}\)|([^()]{1,80})", "", value).strip() + + +def glossary_match_variants(key: str, value: str) -> list[str]: + variants: list[str] = [] + key = key.strip() + clean_value = value.split("#", 1)[0].strip() + if key: + variants.append(key) + variants.extend(parenthetical_aliases(key)) + variants.extend(parenthetical_aliases(clean_value)) + deduped: list[str] = [] + for item in variants: + if len(item) < 2 or item in deduped: + continue + deduped.append(item) + return deduped + + +def glossary_prompt_line(key: str, value: str, source: str) -> str: + clean_value = value.split("#", 1)[0].strip() + value_aliases = parenthetical_aliases(clean_value) + keep_parenthetical = bool(parenthetical_aliases(key)) or any(alias in source for alias in value_aliases) + translation = clean_value if keep_parenthetical else strip_parenthetical_aliases(clean_value) or clean_value + return f"{key} => {translation}" + + +def glossary_matches_for_prompt(review_root: Path, row: dict[str, Any], limit: int = GLOSSARY_PROMPT_LIMIT) -> list[str]: try: glossary = read_glossary(review_root) except ValueError: raise except Exception as exc: raise ValueError(f"读取术语表失败:{exc}") from exc - if not glossary and not default_glossary_path().exists(): - return ["Falchion/ファルシオン:大砍刀,不要译成偃月刀。"] - source = f"{row.get('jp_text', '')}\n{row.get('jp_html', '')}\n{strip_tags(row_translation_html(row))}\n{row_translation_html(row)}" + if not glossary: + return [] + source = "\n".join( + [ + str(row.get("jp_text") or ""), + strip_html_keep_ruby_text(str(row.get("jp_html") or "")), + ] + ) selected: list[str] = [] for key, value in glossary.items(): if not isinstance(key, str) or not isinstance(value, str): @@ -492,13 +615,95 @@ def glossary_matches_for_prompt(review_root: Path, row: dict[str, Any], limit: i clean_value = value.split("#", 1)[0].strip() if not clean_value: continue - if (key and key in source) or (clean_value and clean_value in source): - selected.append(f"{key} => {clean_value}") + if any(variant in source for variant in glossary_match_variants(key, value)): + selected.append(glossary_prompt_line(key, value, source)) + if len(selected) >= limit: + break + return selected[:limit] + + +def sanitize_source_html_for_prompt(fragment: str) -> str: + allowed = {"p", "br", "ruby", "rb", "rt", "rp", "span", "em", "strong", "b", "i"} + + def clean_tag(match: re.Match[str]) -> str: + raw = match.group(0) + closing = raw.startswith("" + if name == "br" or raw.rstrip().endswith("/>"): + return f"<{name} />" + return f"<{name}>" + + return re.sub(r"<[^>]+>", clean_tag, fragment or "") + + +def glossary_mapping_from_data(data: Any) -> dict[str, str]: + glossary: dict[str, str] = {} + if isinstance(data, dict) and isinstance(data.get("terms"), list): + for item in data.get("terms", []): + if not isinstance(item, dict): + continue + source = str(item.get("source") or "").strip() + target = str(item.get("target") or "").strip() + if not source or not target: + continue + target_rt = str(item.get("target_rt") or "").strip() + mode = str(item.get("mode") or "").strip() + value = f"{target}({target_rt})" if target_rt and mode == "ruby" else target + glossary[source] = value + aliases = item.get("aliases") or [] + if isinstance(aliases, list): + for alias in aliases: + alias_text = str(alias or "").strip() + if alias_text and alias_text not in glossary: + glossary[alias_text] = value + rt = str(item.get("rt") or "").strip() + if rt and rt not in glossary: + glossary[rt] = value + return glossary + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(key, str) and isinstance(value, str): + glossary[key] = value + return glossary + + +def read_glossary_mapping_from_path(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + try: + data = read_json(path, {}) + except json.JSONDecodeError as exc: + raise ValueError(f"术语表 JSON 解析失败:{exc}") from exc + return glossary_mapping_from_data(data) + + +def glossary_matches_for_source( + glossary: dict[str, str], + source_html: str, + source_text: str = "", + limit: int = GLOSSARY_PROMPT_LIMIT, +) -> list[str]: + if not glossary: + return [] + source = "\n".join([str(source_text or ""), strip_html_keep_ruby_text(source_html or "")]) + selected: list[str] = [] + for key, value in glossary.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + clean_value = value.split("#", 1)[0].strip() + if not clean_value: + continue + if any(variant in source for variant in glossary_match_variants(key, value)): + selected.append(glossary_prompt_line(key, value, source)) if len(selected) >= limit: break - falchion_rule = "Falchion/ファルシオン => 大砍刀,不要译成偃月刀。" - if not any("Falchion" in item or "ファルシオン" in item for item in selected): - selected.append(falchion_rule) return selected[:limit] @@ -517,6 +722,27 @@ def row_source_html(row: dict[str, Any]) -> str: return str(row.get("jp_html") or row.get("jp_text") or "") +def row_source_html_for_prompt(row: dict[str, Any]) -> str: + allowed = {"p", "br", "ruby", "rb", "rt", "rp", "span", "em", "strong", "b", "i"} + + def clean_tag(match: re.Match[str]) -> str: + raw = match.group(0) + closing = raw.startswith("" + if name == "br" or raw.rstrip().endswith("/>"): + return f"<{name} />" + return f"<{name}>" + + return re.sub(r"<[^>]+>", clean_tag, row_source_html(row)) + + def build_retranslate_context(row: dict[str, Any], rows: list[dict[str, Any]], radius: int = 1) -> list[str]: same_file_rows = [item for item in rows if item.get("file") == row.get("file")] index = next((idx for idx, item in enumerate(same_file_rows) if item.get("id") == row.get("id")), -1) @@ -530,10 +756,7 @@ def build_retranslate_context(row: dict[str, Any], rows: list[dict[str, Any]], r if item.get("id") == row.get("id"): continue direction = "前文" if idx < index else "后文" - context.append( - f"{direction} {item.get('id', '')}: 日文:{truncate_prompt_text(item.get('jp_text'), 500)} " - f"现译:{truncate_prompt_text(strip_tags(row_translation_html(item)), 500)}" - ) + context.append(f"{direction} {item.get('id', '')}: {truncate_prompt_text(item.get('jp_text'), 260)}") return context @@ -616,30 +839,43 @@ def build_retranslate_messages( context_lines = build_retranslate_context(row, rows) system_prompt = "\n\n".join( [ - "你是日语轻小说到简体中文的审校重翻助手。重翻只处理一个目标语句/段落,保持上下文简洁。", - "【翻译内容提示词】", - str(config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT).strip(), - "【格式与标点提示词】", - str(config.get("format_prompt") or DEFAULT_FORMAT_PROMPT).strip(), + "你是日语轻小说到简体中文的审校重翻助手。只处理一个目标语句/段落。", + "只输出新的简体中文译文 HTML 片段,不解释,不输出 Markdown。", + "不得合并、拆分、补写或删除目标段落信息。", + ] + ) + user_parts = [ + "【翻译内容提示词】", + str(config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT).strip(), + "", + "【目标语句:日文原文 HTML】", + row_source_html_for_prompt(row), + ] + if glossary_text: + user_parts.extend(["", "【本段命中术语】", glossary_text]) + if context_lines: + user_parts.extend(["", "【少量上下文】", "\n".join(f"- {item}" for item in context_lines)]) + user_parts.extend( + [ + "", "【角色状态与口吻提示词】", str(config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT).strip(), ] ) - user_parts = [ - "请根据以下信息重翻目标语句,只输出新的中文译文 HTML 片段。", - "", - "【目标语句:日文原文 HTML】", - row_source_html(row), - "", - "【当前中文译文 HTML】", - row_translation_html(row), - ] - if context_lines: - user_parts.extend(["", "【少量上下文】", "\n".join(f"- {item}" for item in context_lines)]) - if glossary_text: - user_parts.extend(["", "【名词表】", glossary_text]) + user_parts.extend( + [ + "【格式与标点提示词】", + str(config.get("format_prompt") or DEFAULT_FORMAT_PROMPT).strip(), + ] + ) if instruction.strip(): - user_parts.extend(["", "【本次额外要求】", instruction.strip()[:1200]]) + user_parts.extend(["", "【本次额外要求】", instruction.strip()[:800]]) + user_parts.extend( + [ + "", + "请只输出目标语句的新中文译文 HTML 片段。", + ] + ) return [ {"role": "system", "content": system_prompt}, {"role": "user", "content": "\n".join(user_parts)}, @@ -1857,6 +2093,474 @@ def export_epub(session_root: Path) -> Path: return out_path +def translation_jobs_root(review_root: Path) -> Path: + return review_root / TRANSLATION_JOB_DIR_NAME + + +def translation_job_root(review_root: Path, job_id: str) -> Path: + safe_id = safe_slug(job_id, max_len=160) + if safe_id != job_id: + raise ValueError("invalid translation job id") + root = (translation_jobs_root(review_root) / job_id).resolve() + try: + root.relative_to(translation_jobs_root(review_root).resolve()) + except ValueError as exc: + raise ValueError("invalid translation job id") from exc + return root + + +def translation_job_path(review_root: Path, job_id: str) -> Path: + return translation_job_root(review_root, job_id) / "job.json" + + +def make_translation_job_id(session_id: str) -> str: + seed = f"{session_id}-{now_iso()}-{time.time_ns()}" + digest = hashlib.sha1(seed.encode("utf-8")).hexdigest()[:8] + return f"tr_{dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_{digest}" + + +def read_translation_job(review_root: Path, job_id: str) -> dict[str, Any]: + return read_json(translation_job_path(review_root, job_id), {}) + + +def write_translation_job(review_root: Path, job: dict[str, Any]) -> None: + job["updated_at"] = now_iso() + write_json_atomic(translation_job_path(review_root, str(job["id"])), job) + + +def add_translation_job_log(job: dict[str, Any], message: str, level: str = "info") -> None: + logs = job.setdefault("logs", []) + logs.append({"ts": now_iso(), "level": level, "message": str(message)[:1200]}) + if len(logs) > TRANSLATION_LOG_LIMIT: + del logs[: len(logs) - TRANSLATION_LOG_LIMIT] + + +def public_translation_job(job: dict[str, Any]) -> dict[str, Any]: + settings = job.get("settings") if isinstance(job.get("settings"), dict) else {} + return { + "id": job.get("id", ""), + "status": job.get("status", ""), + "source_session_id": job.get("source_session_id", ""), + "source_name": job.get("source_name", ""), + "created_at": job.get("created_at", ""), + "updated_at": job.get("updated_at", ""), + "started_at": job.get("started_at", ""), + "finished_at": job.get("finished_at", ""), + "progress": job.get("progress", {}), + "error": job.get("error", ""), + "logs": job.get("logs", []), + "output_epub": job.get("output_epub", ""), + "output_session_id": job.get("output_session_id", ""), + "settings_summary": { + "output_mode": settings.get("output_mode", "bilingual"), + "range_mode": settings.get("range_mode", "limit"), + "limit": settings.get("limit", DEFAULT_TRANSLATION_LIMIT), + "glossary_path": settings.get("glossary_path", ""), + "temperature": settings.get("temperature", 0.2), + }, + } + + +def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]: + output_mode = str(data.get("output_mode") or "bilingual").strip().lower() + if output_mode not in {"bilingual", "translated"}: + raise ValueError("输出模式必须是 bilingual 或 translated") + range_mode = str(data.get("range_mode") or "limit").strip().lower() + if range_mode not in {"all", "limit"}: + raise ValueError("翻译范围必须是 all 或 limit") + try: + limit = int(data.get("limit") or DEFAULT_TRANSLATION_LIMIT) + except (TypeError, ValueError) as exc: + raise ValueError("试译段数必须是数字") from exc + limit = max(1, min(limit, TRANSLATION_RANGE_LIMIT_MAX)) + try: + temperature = float(data.get("temperature", 0.2)) + except (TypeError, ValueError) as exc: + raise ValueError("temperature 必须是数字") from exc + temperature = max(0.0, min(1.0, temperature)) + glossary_value = str(data.get("glossary_path") or config.get("glossary_path") or "").strip() + return { + "output_mode": output_mode, + "range_mode": range_mode, + "limit": limit, + "temperature": temperature, + "glossary_path": normalize_glossary_path_value(glossary_value), + "translation_prompt": normalize_gpt_prompt("translation_prompt", data.get("translation_prompt") or config.get("translation_prompt")), + "format_prompt": normalize_gpt_prompt("format_prompt", data.get("format_prompt") or config.get("format_prompt")), + "character_prompt": normalize_gpt_prompt("character_prompt", data.get("character_prompt") or config.get("character_prompt")), + } + + +def html_looks_like_navigation(entry_name: str, text: str) -> bool: + basename = posixpath.basename(entry_name).lower() + if basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename: + return True + heading = extract_heading_from_html(text) + if re.search(r"(目录|目次|contents?)", heading, flags=re.I): + return True + matches = list(P_RE.finditer(text)) + if len(matches) < 4: + return False + link_count = sum(1 for match in matches if re.search(r"]*href=", match.group(2), flags=re.I)) + return link_count / max(1, len(matches)) >= 0.45 + + +def extract_translation_source_items(session_root: Path) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + serial = 0 + for file_order, entry_name in enumerate(html_entries_in_reading_order(session_root), start=1): + path = extracted_path(session_root, entry_name) + if not path.exists(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + if html_looks_like_navigation(entry_name, text): + continue + document_title = read_document_title(session_root, entry_name) or Path(entry_name).stem + file_serial = 0 + for p_index, match in enumerate(P_RE.finditer(text)): + inner = match.group(2) + source_text = strip_tags(inner) + if not source_text: + continue + if IMG_TAG_RE.search(inner) and not source_text: + continue + if not KANA_RE.search(strip_html_keep_ruby_text(inner)): + continue + serial += 1 + file_serial += 1 + items.append( + { + "id": f"T{serial:05d}", + "file": entry_name, + "file_order": file_order, + "file_row_index": file_serial, + "file_label": Path(entry_name).name, + "document_title": document_title, + "p_index": p_index, + "p_open": match.group(1), + "p_close": match.group(3), + "source_html": inner, + "source_text": source_text, + } + ) + return items + + +def translation_context_lines(items: list[dict[str, Any]], index: int, radius: int = TRANSLATION_CONTEXT_RADIUS) -> list[str]: + start = max(0, index - radius) + end = min(len(items), index + radius + 1) + context: list[str] = [] + for idx in range(start, end): + if idx == index: + continue + item = items[idx] + direction = "前文" if idx < index else "后文" + context.append(f"{direction} {item.get('id', '')}: {truncate_prompt_text(item.get('source_text'), 260)}") + return context + + +def build_translation_messages( + item: dict[str, Any], + items: list[dict[str, Any]], + index: int, + settings: dict[str, Any], + glossary_lines: list[str], +) -> list[dict[str, str]]: + glossary_text = "\n".join(f"- {line}" for line in glossary_lines) + context_lines = translation_context_lines(items, index) + system_prompt = "\n\n".join( + [ + "你是日语轻小说到简体中文的整书翻译助手。每次只处理一个源文段落。", + "只输出该段落的简体中文译文 HTML 片段,不解释,不输出 Markdown。", + "不得合并、拆分、补写或删除源文信息;意义优先,再改写成自然中文轻小说文风。", + ] + ) + user_parts = [ + "【翻译内容提示词】", + str(settings.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT).strip(), + "", + "【目标段落:日文原文 HTML】", + f"

{sanitize_source_html_for_prompt(item.get('source_html', ''))}

", + ] + if glossary_text: + user_parts.extend(["", "【本段命中术语】", glossary_text]) + if context_lines: + user_parts.extend(["", "【少量上下文】", "\n".join(f"- {line}" for line in context_lines)]) + user_parts.extend( + [ + "", + "【角色状态与口吻提示词】", + str(settings.get("character_prompt") or DEFAULT_CHARACTER_PROMPT).strip(), + "", + "【格式与标点提示词】", + str(settings.get("format_prompt") or DEFAULT_FORMAT_PROMPT).strip(), + "", + "请只输出这个段落的中文译文 HTML 片段,不要包含外层

。", + ] + ) + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "\n".join(user_parts)}, + ] + + +def add_gray_style_to_p(open_tag: str) -> str: + def replace_style(match: re.Match[str]) -> str: + quote = match.group(1) + value = match.group(2).strip() + if re.search(r"\bcolor\s*:", value, flags=re.I): + value = re.sub(r"\bcolor\s*:\s*[^;]+;?", "color: #808080;", value, count=1, flags=re.I) + return f"style={quote}{value}{quote}" + suffix = "" if not value or value.endswith(";") else ";" + return f'style={quote}{value}{suffix} color: #808080;{quote}' + + if re.search(r"\sstyle\s*=", open_tag, flags=re.I): + return re.sub(r"style\s*=\s*(['\"])(.*?)\1", replace_style, open_tag, count=1, flags=re.I | re.S) + return re.sub(r">\s*$", ' style="color: #808080;">', open_tag) + + +def replace_translated_paragraphs( + text: str, + replacements: dict[int, str], + output_mode: str, +) -> str: + parts: list[str] = [] + last = 0 + for index, match in enumerate(P_RE.finditer(text)): + parts.append(text[last:match.start()]) + if index not in replacements: + parts.append(match.group(0)) + elif output_mode == "bilingual": + source_open = add_gray_style_to_p(match.group(1)) + parts.append(source_open) + parts.append(match.group(2)) + parts.append(match.group(3)) + parts.append("\n

") + parts.append(replacements[index]) + parts.append("

") + else: + parts.append(match.group(1)) + parts.append(replacements[index]) + parts.append(match.group(3)) + last = match.end() + parts.append(text[last:]) + return "".join(parts) + + +def build_translation_output_epub( + session_root: Path, + source_epub: Path, + source_name: str, + items: list[dict[str, Any]], + translations: dict[str, str], + output_mode: str, + out_path: Path, +) -> Path: + entries = read_json(session_root / "review_state" / "epub_entries.json", []) + by_file: dict[str, dict[int, str]] = {} + for item in items: + translated = translations.get(item["id"]) + if translated is None: + continue + by_file.setdefault(item["file"], {})[int(item["p_index"])] = translated + + out_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(out_path, "w") as zf: + names_written: set[str] = set() + mimetype = extracted_path(session_root, "mimetype") + if mimetype.exists(): + zf.write(mimetype, "mimetype", compress_type=zipfile.ZIP_STORED) + names_written.add("mimetype") + for entry in entries: + name = entry["filename"] + if name in names_written: + continue + path = extracted_path(session_root, name) + if not path.exists(): + continue + compress = zipfile.ZIP_STORED if name == "mimetype" else zipfile.ZIP_DEFLATED + if entry.get("is_dir"): + info = zipfile.ZipInfo(name) + zf.writestr(info, b"") + elif name in by_file and name.lower().endswith((".xhtml", ".html", ".htm")): + text = path.read_text(encoding="utf-8", errors="replace") + updated = replace_translated_paragraphs(text, by_file[name], output_mode) + zf.writestr(name, updated.encode("utf-8"), compress_type=compress) + else: + zf.write(path, name, compress_type=compress) + names_written.add(name) + return out_path + + +def translation_output_path(review_root: Path, source_name: str, output_mode: str) -> Path: + suffix = "中日双语" if output_mode == "bilingual" else "中文" + ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + stem = safe_slug(Path(source_name).stem or "translated_epub", max_len=100) + return review_root / TRANSLATION_OUTPUT_DIR_NAME / f"{stem}_AI翻译_{suffix}_{ts}.epub" + + +def register_output_session(review_root: Path, out_path: Path) -> str: + session_root = session_root_for(review_root, out_path) + ensure_state(session_root, out_path, reset=True) + write_session_manifest(session_root, out_path, source_name=out_path.name) + return session_public_id(session_root) + + +def run_translation_job(review_root: Path, job_id: str) -> None: + job = read_translation_job(review_root, job_id) + if not job: + return + translations: dict[str, str] = {} + selected_items: list[dict[str, Any]] = [] + try: + job["status"] = "running" + job["started_at"] = now_iso() + job["error"] = "" + job["progress"] = { + "current": 0, + "total": 0, + "percent": 0, + "failed": 0, + "message": "正在准备源 EPUB……", + "current_file": "", + } + add_translation_job_log(job, "翻译任务已启动。") + write_translation_job(review_root, job) + + source_session = session_root_from_id(review_root, str(job.get("source_session_id") or "")) + if not (source_session / "review_state" / "state.json").exists(): + raise ValueError("源书籍会话不存在或不完整") + source_epub = session_epub_path(source_session) + if not source_epub.exists(): + raise ValueError("源 EPUB 文件不存在") + settings = job.get("settings") if isinstance(job.get("settings"), dict) else {} + config = read_gpt_config(review_root) + if not config.get("configured"): + raise ValueError("尚未配置 GPT API Key") + config = { + **config, + "base_url": str(settings.get("base_url") or config.get("base_url") or DEFAULT_GPT_BASE_URL), + "model": str(settings.get("model") or config.get("model") or DEFAULT_GPT_MODEL), + } + glossary_file = Path(str(settings.get("glossary_path") or config.get("glossary_path") or "")) + glossary = read_glossary_mapping_from_path(glossary_file) + + all_items = extract_translation_source_items(source_session) + source_total = len(all_items) + if settings.get("range_mode") == "all": + selected_items = all_items + else: + selected_items = all_items[: int(settings.get("limit") or DEFAULT_TRANSLATION_LIMIT)] + if not selected_items: + raise ValueError("没有找到可翻译的日文正文段落") + + job["progress"].update( + { + "total": len(selected_items), + "source_total": source_total, + "message": f"已找到 {source_total} 个日文段落,开始翻译 {len(selected_items)} 段。", + } + ) + add_translation_job_log(job, job["progress"]["message"]) + write_translation_job(review_root, job) + + failed_count = 0 + errors: list[dict[str, Any]] = [] + for index, item in enumerate(selected_items): + job["progress"].update( + { + "current": index, + "percent": round(index / max(1, len(selected_items)) * 100, 1), + "message": f"正在翻译 {item['id']}({index + 1}/{len(selected_items)})", + "current_file": item.get("file", ""), + "current_item_id": item.get("id", ""), + } + ) + write_translation_job(review_root, job) + glossary_lines = glossary_matches_for_source(glossary, item.get("source_html", ""), item.get("source_text", "")) + try: + raw = call_openai_compatible_chat( + config, + build_translation_messages(item, selected_items, index, settings, glossary_lines), + temperature=float(settings.get("temperature", 0.2)), + timeout=120, + ) + candidate_html = sanitize_cn_html( + restore_glossary_ruby_candidates(normalize_candidate_punctuation(raw), glossary_lines) + ) + if not candidate_html or not strip_tags(candidate_html): + raise RuntimeError("GPT 返回内容为空或无法作为译文保存") + translations[item["id"]] = candidate_html + except Exception as exc: + failed_count += 1 + error_text = str(exc)[:900] + errors.append({"item_id": item["id"], "file": item.get("file", ""), "p_index": item.get("p_index"), "error": error_text}) + translations[item["id"]] = f"【本段 AI 翻译失败:{html.escape(error_text)}】" + add_translation_job_log(job, f"{item['id']} 翻译失败:{error_text}", level="warning") + if index == 0 or (index + 1) % 5 == 0 or failed_count: + write_json_atomic( + translation_job_root(review_root, job_id) / "translations.json", + {"items": selected_items, "translations": translations, "errors": errors}, + ) + + job["progress"].update( + { + "current": len(selected_items), + "percent": 100, + "failed": failed_count, + "message": "正在生成 EPUB……", + "current_item_id": "", + } + ) + write_translation_job(review_root, job) + source_name = str(job.get("source_name") or source_epub.name) + out_path = translation_output_path(review_root, source_name, str(settings.get("output_mode") or "bilingual")) + build_translation_output_epub( + source_session, + source_epub, + source_name, + selected_items, + translations, + str(settings.get("output_mode") or "bilingual"), + out_path, + ) + output_session_id = register_output_session(review_root, out_path) + job["status"] = "completed" + job["finished_at"] = now_iso() + job["output_epub"] = str(out_path) + job["output_session_id"] = output_session_id + job["errors"] = errors + job["progress"].update( + { + "message": f"翻译完成,已生成 EPUB 并加入书架。失败段落 {failed_count} 个。", + "failed": failed_count, + } + ) + add_translation_job_log(job, job["progress"]["message"]) + write_translation_job(review_root, job) + append_jsonl( + source_session / "feedback" / "translation_feedback.jsonl", + { + "ts": now_iso(), + "event": "translation_job_completed", + "job_id": job_id, + "output": str(out_path), + "output_session_id": output_session_id, + "selected_count": len(selected_items), + "failed_count": failed_count, + "glossary_path": str(settings.get("glossary_path") or ""), + }, + ) + except Exception as exc: + job = read_translation_job(review_root, job_id) or job + job["status"] = "failed" + job["finished_at"] = now_iso() + job["error"] = str(exc)[:1200] + job.setdefault("progress", {}).update({"message": f"翻译失败:{job['error']}"}) + add_translation_job_log(job, job["error"], level="error") + write_translation_job(review_root, job) + + def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: bool = False) -> Flask: app = Flask( __name__, @@ -1865,11 +2569,13 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: static_url_path="/static", ) review_root.mkdir(parents=True, exist_ok=True) + translation_jobs_root(review_root).mkdir(parents=True, exist_ok=True) app.config["REVIEW_ROOT"] = review_root app.config["APP_VERSION"] = version app.config["SESSION_ROOT"] = None app.config["EPUB_PATH"] = None app.config["LOCK"] = threading.Lock() + app.config["TRANSLATION_THREADS"] = {} def set_active_session(session_root: Path, epub_path: Path | None = None) -> None: app.config["SESSION_ROOT"] = session_root @@ -2137,6 +2843,135 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: except ValueError as exc: return jsonify({"error": str(exc)}), 400 + @app.route("/api/translation/defaults") + def api_translation_defaults(): + config = public_gpt_config(review_root) + return jsonify( + { + "version": version, + "gpt": config, + "defaults": { + "output_mode": "bilingual", + "range_mode": "limit", + "limit": DEFAULT_TRANSLATION_LIMIT, + "temperature": 0.2, + "translation_prompt": config.get("translation_prompt") or DEFAULT_TRANSLATION_PROMPT, + "format_prompt": config.get("format_prompt") or DEFAULT_FORMAT_PROMPT, + "character_prompt": config.get("character_prompt") or DEFAULT_CHARACTER_PROMPT, + "glossary_path": config.get("glossary_path", ""), + }, + "prompt_defaults": gpt_prompt_defaults(), + } + ) + + @app.route("/api/session//translation-source") + def api_translation_source(session_id: str): + try: + source_root = session_root_from_id(review_root, session_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not (source_root / "review_state" / "state.json").exists(): + return jsonify({"error": "会话不存在或不完整"}), 404 + try: + items = extract_translation_source_items(source_root) + files: dict[str, int] = {} + for item in items: + files[item["file"]] = files.get(item["file"], 0) + 1 + return jsonify( + { + "session": summarize_session(source_root), + "source_count": len(items), + "files": [{"file": key, "count": value} for key, value in files.items()], + "sample": [ + { + "id": item["id"], + "file": item["file"], + "p_index": item["p_index"], + "text": truncate_prompt_text(item["source_text"], 140), + } + for item in items[:5] + ], + } + ) + except Exception as exc: + return jsonify({"error": f"读取翻译源失败:{exc}"}), 500 + + @app.route("/api/translation/start", methods=["POST"]) + def api_translation_start(): + data = request.get_json(silent=True) or {} + session_id = str(data.get("session_id") or "").strip() + if not session_id: + return jsonify({"error": "缺少 session_id"}), 400 + try: + source_root = session_root_from_id(review_root, session_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not (source_root / "review_state" / "state.json").exists(): + return jsonify({"error": "会话不存在或不完整"}), 404 + try: + requested_base_url = str(data.get("base_url") or "").strip() + requested_model = str(data.get("model") or "").strip() + config = read_gpt_config(review_root) + if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]: + return jsonify({"error": "整书翻译不能临时覆盖 Base URL。请先保存 API 设置。"}), 400 + if requested_model and requested_model != config["model"]: + return jsonify({"error": "整书翻译不能临时覆盖模型。请先保存 API 设置。"}), 400 + if not config.get("configured"): + return jsonify({"error": "尚未配置 GPT API Key。请先保存 API 设置。"}), 400 + settings = normalize_translation_job_settings(data, config) + manifest = read_json(session_manifest_path(source_root), {}) + source_epub = session_epub_path(source_root) + job_id = make_translation_job_id(session_id) + job = { + "id": job_id, + "status": "pending", + "source_session_id": session_id, + "source_epub": str(source_epub), + "source_name": manifest.get("source_name") or source_epub.name, + "created_at": now_iso(), + "updated_at": now_iso(), + "started_at": "", + "finished_at": "", + "progress": { + "current": 0, + "total": 0, + "percent": 0, + "failed": 0, + "message": "任务已创建,等待后台启动。", + "current_file": "", + "current_item_id": "", + }, + "settings": { + **settings, + "model": config["model"], + "base_url": config["base_url"], + }, + "output_epub": "", + "output_session_id": "", + "error": "", + "logs": [], + } + add_translation_job_log(job, "任务已创建。") + write_translation_job(review_root, job) + thread = threading.Thread(target=run_translation_job, args=(review_root, job_id), daemon=True) + app.config["TRANSLATION_THREADS"][job_id] = thread + thread.start() + return jsonify({"status": "ok", "job_id": job_id, "job": public_translation_job(job)}) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except Exception as exc: + return jsonify({"error": f"创建翻译任务失败:{exc}"}), 500 + + @app.route("/api/translation/jobs/") + def api_translation_job(job_id: str): + try: + job = read_translation_job(review_root, job_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not job: + return jsonify({"error": "翻译任务不存在"}), 404 + return jsonify({"job": public_translation_job(job)}) + @app.route("/api/row//retranslate", methods=["POST"]) def api_retranslate_row(row_id: str): session_root, _epub_path = active_session() @@ -2338,8 +3173,7 @@ def wait_until_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> boo def run_daemon(args: argparse.Namespace) -> int: - bind_host = display_host(args.host) - port = find_free_port(args.port, bind_host) + port = find_free_port(args.port, args.host) review_root = Path(args.review_root).resolve() review_root.mkdir(parents=True, exist_ok=True) if args.epub: @@ -2437,8 +3271,7 @@ def main(argv: list[str] | None = None) -> int: review_root = Path(args.review_root).resolve() app = create_app(review_root, epub_path, reset=args.reset) - bind_host = display_host(args.host) - port = find_free_port(args.port, bind_host) + port = find_free_port(args.port, args.host) url = f"http://{display_host(args.host)}:{port}" if not args.no_browser: try: diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index 698090d..15637b1 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -26,17 +26,33 @@ let readingPositionInFlight = false; let readingPositionSavePromise = null; let pendingReadingPosition = null; let readingPositionReady = false; +let readerSettings = { theme: "default", fontSize: 17, lineHeight: 1.95 }; +let chromeHideTimer = null; +let chromeResizeObserver = null; +let chromePointerNearTop = false; +let translationSessionId = ""; +let translationJobId = ""; +let translationOutputSessionId = ""; +let translationPollTimer = null; +let translationPromptDefaults = {}; + +const READER_SETTINGS_KEY = "epubReviewReaderSettings"; +const READER_THEMES = new Set(["default", "eye", "night"]); const $ = (id) => document.getElementById(id); const dom = { startScreen: $("startScreen"), + bookshelfScreen: $("bookshelfScreen"), + translationScreen: $("translationScreen"), reviewLayout: $("reviewLayout"), appVersion: $("appVersion"), sessionMeta: $("sessionMeta"), + bookshelfBtn: $("bookshelfBtn"), readingModeBtn: $("readingModeBtn"), polishModeBtn: $("polishModeBtn"), openLibraryBtn: $("openLibraryBtn"), + reviewToolsBtn: $("reviewToolsBtn"), showTouchedBtn: $("showTouchedBtn"), feedbackBtn: $("feedbackBtn"), applyBtn: $("applyBtn"), @@ -63,6 +79,13 @@ const dom = { readingFlow: $("readingFlow"), prevPartBtn: $("prevPartBtn"), nextPartBtn: $("nextPartBtn"), + themeDefaultBtn: $("themeDefaultBtn"), + themeEyeBtn: $("themeEyeBtn"), + themeNightBtn: $("themeNightBtn"), + readerFontSizeInput: $("readerFontSizeInput"), + readerFontSizeValue: $("readerFontSizeValue"), + readerLineHeightInput: $("readerLineHeightInput"), + readerLineHeightValue: $("readerLineHeightValue"), emptyState: $("emptyState"), editorCard: $("editorCard"), rowId: $("rowId"), @@ -96,7 +119,6 @@ const dom = { quickMarkSelectionBtn: $("quickMarkSelectionBtn"), quickSaveBtn: $("quickSaveBtn"), quickOpenFullBtn: $("quickOpenFullBtn"), - toggleGptConfigBtn: $("toggleGptConfigBtn"), gptStatus: $("gptStatus"), gptConfig: $("gptConfig"), gptBaseUrlInput: $("gptBaseUrlInput"), @@ -125,6 +147,36 @@ const dom = { refreshSessionsBtn: $("refreshSessionsBtn"), sessionList: $("sessionList"), sessionListMeta: $("sessionListMeta"), + bookshelfRefreshBtn: $("bookshelfRefreshBtn"), + bookshelfOpenUploadBtn: $("bookshelfOpenUploadBtn"), + bookshelfMeta: $("bookshelfMeta"), + bookshelfList: $("bookshelfList"), + translationBookMeta: $("translationBookMeta"), + translationBackBookshelfBtn: $("translationBackBookshelfBtn"), + translationOpenReviewBtn: $("translationOpenReviewBtn"), + translationForm: $("translationForm"), + translationSourceMeta: $("translationSourceMeta"), + translationOutputModeInput: $("translationOutputModeInput"), + translationRangeModeInput: $("translationRangeModeInput"), + translationLimitInput: $("translationLimitInput"), + translationTemperatureInput: $("translationTemperatureInput"), + translationSample: $("translationSample"), + translationGptStatus: $("translationGptStatus"), + translationBaseUrlInput: $("translationBaseUrlInput"), + translationModelInput: $("translationModelInput"), + translationApiKeyInput: $("translationApiKeyInput"), + translationGlossaryPathInput: $("translationGlossaryPathInput"), + translationSaveConfigBtn: $("translationSaveConfigBtn"), + translationResetPromptsBtn: $("translationResetPromptsBtn"), + translationPromptInput: $("translationPromptInput"), + translationFormatPromptInput: $("translationFormatPromptInput"), + translationCharacterPromptInput: $("translationCharacterPromptInput"), + translationStartBtn: $("translationStartBtn"), + translationJobMeta: $("translationJobMeta"), + translationProgressBar: $("translationProgressBar"), + translationProgressText: $("translationProgressText"), + translationOutput: $("translationOutput"), + translationLogs: $("translationLogs"), toast: $("toast"), }; @@ -179,11 +231,134 @@ function rowTouched(row) { return Boolean(row.edited || row.marked || row.comment || row.learn_note || row.issue_type || row.severity || row.tags); } +function clampNumber(value, min, max, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function readStoredReaderSettings() { + try { + const stored = JSON.parse(localStorage.getItem(READER_SETTINGS_KEY) || "{}"); + readerSettings = { + theme: READER_THEMES.has(stored.theme) ? stored.theme : "default", + fontSize: clampNumber(stored.fontSize, 15, 24, 17), + lineHeight: clampNumber(stored.lineHeight, 1.6, 2.4, 1.95), + }; + } catch (_error) { + readerSettings = { theme: "default", fontSize: 17, lineHeight: 1.95 }; + } +} + +function persistReaderSettings() { + try { + localStorage.setItem(READER_SETTINGS_KEY, JSON.stringify(readerSettings)); + } catch (_error) { + // Browser storage can be unavailable in restricted contexts; reading still works. + } +} + +function applyReaderSettings(save = false) { + document.body.dataset.readerTheme = readerSettings.theme; + document.documentElement.style.setProperty("--reader-font-size", `${readerSettings.fontSize}px`); + document.documentElement.style.setProperty("--reader-line-height", readerSettings.lineHeight.toFixed(2)); + [ + dom.themeDefaultBtn, + dom.themeEyeBtn, + dom.themeNightBtn, + ].forEach((button) => { + if (!button) return; + const active = button.dataset.readerTheme === readerSettings.theme; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", active ? "true" : "false"); + }); + if (dom.readerFontSizeInput) dom.readerFontSizeInput.value = String(readerSettings.fontSize); + if (dom.readerFontSizeValue) dom.readerFontSizeValue.textContent = String(readerSettings.fontSize); + if (dom.readerLineHeightInput) dom.readerLineHeightInput.value = readerSettings.lineHeight.toFixed(2); + if (dom.readerLineHeightValue) dom.readerLineHeightValue.textContent = readerSettings.lineHeight.toFixed(2); + if (save) persistReaderSettings(); +} + +function setReaderTheme(theme) { + if (!READER_THEMES.has(theme)) return; + withReaderAnchor(() => { + readerSettings.theme = theme; + applyReaderSettings(true); + }); +} + +function setReaderFontSize(value) { + withReaderAnchor(() => { + readerSettings.fontSize = Math.round(clampNumber(value, 15, 24, 17)); + applyReaderSettings(true); + }); +} + +function setReaderLineHeight(value) { + withReaderAnchor(() => { + readerSettings.lineHeight = clampNumber(value, 1.6, 2.4, 1.95); + applyReaderSettings(true); + }); +} + +function showChromeBriefly() { + document.body.classList.add("chromeVisible"); + clearTimeout(chromeHideTimer); + chromeHideTimer = setTimeout(() => { + if ( + chromePointerNearTop + || document.querySelector(".topbar:hover, .topbar:focus-within, .readerHeader:hover, .readerHeader:focus-within") + ) { + showChromeBriefly(); + return; + } + document.body.classList.remove("chromeVisible"); + }, 1600); +} + +function updateChromeVisibility(event) { + if (!hasSession || document.body.classList.contains("startOpen")) { + chromePointerNearTop = false; + document.body.classList.remove("chromeVisible"); + return; + } + chromePointerNearTop = event.clientY <= 104; + if (chromePointerNearTop) { + showChromeBriefly(); + } +} + +function syncChromeMetrics() { + const topbar = document.querySelector(".topbar"); + if (!topbar) return; + const height = Math.max(0, Math.round(topbar.getBoundingClientRect().height || 0)); + if (height) document.documentElement.style.setProperty("--topbar-live-height", `${height}px`); +} + +function initChromeMetrics() { + syncChromeMetrics(); + window.addEventListener("resize", syncChromeMetrics); + if ("ResizeObserver" in window) { + chromeResizeObserver = new ResizeObserver(syncChromeMetrics); + const topbar = document.querySelector(".topbar"); + if (topbar) chromeResizeObserver.observe(topbar); + } +} + +function readerViewportTop(paneRect) { + const header = document.querySelector(".readerHeader"); + if (!header) return paneRect.top + 8; + const headerRect = header.getBoundingClientRect(); + const headerVisible = Number.parseFloat(getComputedStyle(header).opacity || "0") > 0.05; + return headerVisible && headerRect.bottom > paneRect.top + ? headerRect.bottom + 8 + : paneRect.top + 8; +} + function readerAnchor() { if (currentMode !== "reading" || !dom.readerPane || dom.readerPane.hidden) return null; const paneRect = dom.readerPane.getBoundingClientRect(); - const headerRect = document.querySelector(".readerHeader")?.getBoundingClientRect(); - const top = headerRect ? headerRect.bottom + 8 : paneRect.top + 8; + const top = readerViewportTop(paneRect); const blocks = Array.from(dom.readingFlow.querySelectorAll("[data-row-id]")); if (!blocks.length) { return { rowId: "", offset: 0, scrollTop: dom.readerPane.scrollTop }; @@ -374,15 +549,37 @@ function resetReviewState() { dom.quickEditor.hidden = true; } document.body.classList.remove("quickOpen"); + if (dom.reviewToolsBtn) dom.reviewToolsBtn.classList.remove("active"); +} + +function closeReviewPanels() { + closeSidePanel(); + if (dom.quickEditor) dom.quickEditor.hidden = true; + if (dom.reviewToolsBtn) dom.reviewToolsBtn.classList.remove("active"); + document.body.classList.remove("quickOpen"); +} + +function clearTranslationPoll() { + if (translationPollTimer) { + clearTimeout(translationPollTimer); + translationPollTimer = null; + } } function setShellMode(active) { hasSession = active; + document.body.classList.remove("bookshelfOpen"); + document.body.classList.remove("translationOpen"); + if (dom.bookshelfBtn) dom.bookshelfBtn.classList.remove("active"); dom.startScreen.hidden = active; + if (active && dom.bookshelfScreen) dom.bookshelfScreen.hidden = true; + if (active && dom.translationScreen) dom.translationScreen.hidden = true; dom.reviewLayout.hidden = !active; dom.readingModeBtn.disabled = !active; dom.polishModeBtn.disabled = !active; + dom.bookshelfBtn.disabled = false; dom.openLibraryBtn.disabled = false; + dom.reviewToolsBtn.disabled = !active; dom.showTouchedBtn.disabled = !active; dom.feedbackBtn.disabled = !active; dom.applyBtn.disabled = !active; @@ -393,12 +590,21 @@ function setShellMode(active) { dom.expandTocBtn.disabled = !active; dom.collapseTocBtn.disabled = !active; document.body.classList.toggle("startOpen", !active); + document.body.classList.remove("chromeVisible"); if (!active) closeSidePanel(); if (!active) { dom.sessionMeta.textContent = "未打开 EPUB"; } } +async function leaveReviewShell() { + if (!hasSession) return true; + if (!(await maybeSaveBeforeSwitch())) return false; + await flushReadingPosition().catch(() => {}); + closeReviewPanels(); + return true; +} + function setVersionBadge(value) { if (!value) return; dom.appVersion.textContent = `v${value}`; @@ -446,11 +652,119 @@ async function refreshSessions() { const data = await api("/api/sessions"); setVersionBadge(data.version); renderSessions(data.sessions || [], data.active_session_id || ""); + return data; +} + +function readingPositionLabel(position = {}) { + if (!position || !position.scope || position.scope === "none") return "尚无阅读记录"; + if (position.scope === "image") return "上次阅读:插图"; + if (position.part_id) return `上次阅读:${position.part_id}`; + if (position.chapter_id) return `上次阅读:${position.chapter_id}`; + if (position.row_id) return `上次阅读:${position.row_id}`; + return "已有阅读记录"; +} + +function renderBookshelf(sessions = [], activeId = "") { + dom.bookshelfList.innerHTML = ""; + dom.bookshelfMeta.textContent = sessions.length ? `${sessions.length} 本历史书籍` : "暂无历史书籍"; + if (!sessions.length) { + dom.bookshelfList.innerHTML = ` +
+

书架还是空的

+

上传第一本中日双语 EPUB 后,它会出现在这里,之后可以直接回到上次阅读位置。

+ +
+ `; + dom.bookshelfList.querySelector("[data-bookshelf-upload]").addEventListener("click", () => showStartScreen()); + return; + } + + const frag = document.createDocumentFragment(); + sessions.forEach((session) => { + const card = document.createElement("article"); + card.className = "bookshelfItem"; + card.tabIndex = 0; + if (session.id === activeId) card.classList.add("active"); + const title = session.source_name || session.id; + const updatedAt = session.updated_at || session.created_at; + const initial = String(title || "").trim().slice(0, 1) || "书"; + card.innerHTML = ` + +
+

${escapeHtml(title)}

+

${escapeHtml(readingPositionLabel(session.reading_position))}

+
+ ${session.row_count || 0} 段 + 记录 ${session.touched_count || 0} + 标记 ${session.marked_count || 0} + ${updatedAt ? `${escapeHtml(formatDateTime(updatedAt))}` : ""} +
+
+
+ + +
+ `; + const open = () => openSession(session.id); + const reviewButton = card.querySelector("[data-open-review]"); + const translateButton = card.querySelector("[data-open-translation]"); + reviewButton.addEventListener("click", (event) => { + event.stopPropagation(); + open(); + }); + translateButton.addEventListener("click", (event) => { + event.stopPropagation(); + showTranslationScreen(session.id).catch((error) => toast(`打开翻译页失败:${error.message}`, 7000)); + }); + card.addEventListener("click", (event) => { + if (event.target.closest("button")) return; + open(); + }); + card.addEventListener("keydown", (event) => { + if (event.target.closest("button")) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + open(); + }); + frag.appendChild(card); + }); + dom.bookshelfList.appendChild(frag); +} + +async function refreshBookshelf() { + dom.bookshelfMeta.textContent = "正在读取书架……"; + const data = await api("/api/sessions"); + setVersionBadge(data.version); + renderBookshelf(data.sessions || [], data.active_session_id || ""); + return data; +} + +async function showBookshelf() { + if (!(await leaveReviewShell())) return; + clearTranslationPoll(); + resetReviewState(); + setShellMode(false); + dom.startScreen.hidden = true; + if (dom.translationScreen) dom.translationScreen.hidden = true; + dom.bookshelfScreen.hidden = false; + document.body.classList.add("bookshelfOpen"); + dom.bookshelfBtn.classList.add("active"); + dom.sessionMeta.textContent = "书架 · 历史审校书籍"; + try { + await refreshBookshelf(); + } catch (error) { + dom.bookshelfMeta.textContent = "书架读取失败"; + dom.bookshelfList.innerHTML = `
读取书架失败:${escapeHtml(error.message)}
`; + } } async function showStartScreen(sessionPayload = null) { + clearTranslationPoll(); resetReviewState(); setShellMode(false); + dom.bookshelfScreen.hidden = true; + if (dom.translationScreen) dom.translationScreen.hidden = true; + dom.startScreen.hidden = false; if (sessionPayload && Array.isArray(sessionPayload.sessions)) { renderSessions(sessionPayload.sessions, ""); } else { @@ -462,6 +776,166 @@ async function showStartScreen(sessionPayload = null) { } } +function renderTranslationSource(payload) { + const session = payload.session || {}; + const sourceCount = payload.source_count || 0; + dom.translationBookMeta.textContent = `${session.source_name || translationSessionId} · 可翻译日文段落 ${sourceCount} 段`; + dom.translationSourceMeta.textContent = sourceCount ? `检测到 ${sourceCount} 个日文正文段落` : "没有检测到可翻译日文段落"; + dom.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000))); + const sample = payload.sample || []; + if (!sample.length) { + dom.translationSample.innerHTML = '
暂无可预览段落。请确认这本书是日语 EPUB。
'; + return; + } + dom.translationSample.innerHTML = sample + .map((item) => ` +
+ ${escapeHtml(item.id)} · ${escapeHtml(item.file || "")} +

${escapeHtml(item.text || "")}

+
+ `) + .join(""); +} + +async function showTranslationScreen(sessionId) { + if (!(await leaveReviewShell())) return; + clearTranslationPoll(); + resetReviewState(); + setShellMode(false); + translationSessionId = sessionId; + translationJobId = ""; + translationOutputSessionId = ""; + dom.startScreen.hidden = true; + dom.bookshelfScreen.hidden = true; + dom.reviewLayout.hidden = true; + dom.translationScreen.hidden = false; + dom.translationOpenReviewBtn.disabled = true; + dom.translationOutput.hidden = true; + dom.translationOutput.textContent = ""; + dom.translationProgressBar.style.width = "0%"; + dom.translationProgressText.textContent = "正在读取源书与 API 配置……"; + dom.translationJobMeta.textContent = "尚未开始"; + dom.translationLogs.innerHTML = ""; + document.body.classList.add("translationOpen"); + dom.bookshelfBtn.classList.remove("active"); + dom.sessionMeta.textContent = "AI 翻译 EPUB"; + const [sourcePayload, defaultsPayload] = await Promise.all([ + api(`/api/session/${encodeURIComponent(sessionId)}/translation-source`), + api("/api/translation/defaults"), + ]); + setVersionBadge(defaultsPayload.version); + renderTranslationGptConfig(defaultsPayload.gpt || {}); + const defaults = defaultsPayload.defaults || {}; + dom.translationOutputModeInput.value = defaults.output_mode || "bilingual"; + dom.translationRangeModeInput.value = defaults.range_mode || "limit"; + dom.translationLimitInput.value = defaults.limit || 20; + dom.translationTemperatureInput.value = defaults.temperature ?? 0.2; + renderTranslationSource(sourcePayload); + dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 段试译。"; +} + +function collectTranslationOptions() { + return { + session_id: translationSessionId, + output_mode: dom.translationOutputModeInput.value, + range_mode: dom.translationRangeModeInput.value, + limit: dom.translationLimitInput.value, + temperature: dom.translationTemperatureInput.value, + base_url: dom.translationBaseUrlInput.value, + model: dom.translationModelInput.value, + glossary_path: dom.translationGlossaryPathInput.value, + translation_prompt: dom.translationPromptInput.value, + format_prompt: dom.translationFormatPromptInput.value, + character_prompt: dom.translationCharacterPromptInput.value, + }; +} + +function renderTranslationJob(job) { + const progress = job.progress || {}; + const percent = Math.max(0, Math.min(100, Number(progress.percent || 0))); + dom.translationProgressBar.style.width = `${percent}%`; + dom.translationJobMeta.textContent = job.id ? `${job.status || "unknown"} · ${percent.toFixed(percent % 1 ? 1 : 0)}%` : "尚未开始"; + const total = progress.total || 0; + const current = progress.current || 0; + const failed = progress.failed || 0; + dom.translationProgressText.textContent = [ + progress.message || "正在处理……", + total ? `进度:${current}/${total}` : "", + progress.current_file ? `当前文件:${progress.current_file}` : "", + failed ? `失败段落:${failed}` : "", + job.error ? `错误:${job.error}` : "", + ].filter(Boolean).join("\n"); + const logs = (job.logs || []).slice(-12).reverse(); + dom.translationLogs.innerHTML = logs.map((log) => ` +
+ ${escapeHtml(formatDateTime(log.ts) || log.ts || "")} +

${escapeHtml(log.message || "")}

+
+ `).join(""); + if (job.status === "completed") { + translationOutputSessionId = job.output_session_id || ""; + dom.translationOpenReviewBtn.disabled = !translationOutputSessionId; + dom.translationOutput.hidden = false; + dom.translationOutput.innerHTML = ` + 已生成 EPUB 并加入书架 +

${escapeHtml(job.output_epub || "")}

+ `; + refreshBookshelf().catch(() => {}); + } +} + +async function pollTranslationProgress() { + if (!translationJobId) return; + try { + const payload = await api(`/api/translation/jobs/${encodeURIComponent(translationJobId)}`); + const job = payload.job || {}; + renderTranslationJob(job); + if (["completed", "failed", "cancelled"].includes(job.status)) { + clearTranslationPoll(); + dom.translationStartBtn.disabled = false; + dom.translationStartBtn.textContent = "开始翻译"; + if (job.status === "completed") toast("翻译完成,输出 EPUB 已加入书架。", 7000); + if (job.status === "failed") toast(`翻译失败:${job.error || "未知错误"}`, 9000); + return; + } + translationPollTimer = setTimeout(pollTranslationProgress, 1200); + } catch (error) { + clearTranslationPoll(); + dom.translationProgressText.textContent = `读取进度失败:${error.message}`; + dom.translationStartBtn.disabled = false; + dom.translationStartBtn.textContent = "开始翻译"; + } +} + +async function startTranslationJob(event) { + event.preventDefault(); + if (!translationSessionId) return; + if (dom.translationRangeModeInput.value === "all" && !window.confirm("全书翻译可能消耗较长时间和较多 API 额度。确认开始吗?")) { + return; + } + try { + dom.translationStartBtn.disabled = true; + dom.translationStartBtn.textContent = "启动中……"; + dom.translationProgressText.textContent = "正在保存设置并创建翻译任务……"; + await saveTranslationGptConfig(); + const payload = await api("/api/translation/start", { + method: "POST", + body: JSON.stringify(collectTranslationOptions()), + }); + translationJobId = payload.job_id; + renderTranslationJob(payload.job || {}); + dom.translationStartBtn.textContent = "翻译运行中……"; + clearTranslationPoll(); + translationPollTimer = setTimeout(pollTranslationProgress, 600); + toast("翻译任务已启动。"); + } catch (error) { + dom.translationStartBtn.disabled = false; + dom.translationStartBtn.textContent = "开始翻译"; + dom.translationProgressText.textContent = `启动失败:${error.message}`; + toast(`启动翻译失败:${error.message}`, 9000); + } +} + function rowCurrentText(row) { return stripTags(row.current_html || row.cn_html || row.cn_text || ""); } @@ -482,6 +956,10 @@ function allParts() { return (structure.chapters || []).flatMap((chapter) => chapter.parts || []); } +function isImageChapter(chapter) { + return chapterKind(chapter) === "image"; +} + function chapterItems(chapter) { if (!chapter) return []; if (Array.isArray(chapter.items) && chapter.items.length) return chapter.items; @@ -510,6 +988,70 @@ function activeChapterScopeId() { return currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id; } +function readingScopeId(chapter, item = null) { + if (!chapter) return ""; + if (!item) return `chapter:${chapter.id}`; + return isImageItem(item) ? `image:${chapter.id}:${item.id}` : `part:${item.id}`; +} + +function scopeForItem(chapter, item) { + if (!item) return null; + if (isImageItem(item)) { + return { type: "image", id: readingScopeId(chapter, item), chapter, item }; + } + return { type: "part", id: readingScopeId(chapter, item), chapter, part: item }; +} + +function allReadingScopes() { + const scopes = []; + for (const chapter of structure.chapters || []) { + const items = chapterItems(chapter); + if (!items.length) { + scopes.push({ type: "chapter", id: readingScopeId(chapter), chapter }); + continue; + } + for (const item of items) { + scopes.push(scopeForItem(chapter, item)); + } + } + return scopes; +} + +function visibleReadingScopeId(chapter) { + if (!chapter || !dom.readerPane || dom.readerPane.hidden) return ""; + const paneRect = dom.readerPane.getBoundingClientRect(); + const top = readerViewportTop(paneRect); + const items = Array.from(dom.readingFlow.querySelectorAll("[data-reading-scope-id]")); + let active = null; + for (const item of items) { + const rect = item.getBoundingClientRect(); + if (rect.bottom >= top) { + active = item; + break; + } + } + return active?.dataset.readingScopeId || ""; +} + +function activeReadingScopeId() { + if (currentImageItem && currentChapter) return readingScopeId(currentChapter, currentImageItem); + if (currentPart) return readingScopeId(chapterForPart(currentPart), currentPart); + if (currentChapter) { + const visibleScope = visibleReadingScopeId(currentChapter); + if (visibleScope) return visibleScope; + const anchor = readerAnchor(); + const anchorRow = anchor && anchor.rowId ? rowById(anchor.rowId) : null; + const anchorPart = partForRow(anchorRow); + if (anchorPart && chapterForPart(anchorPart)?.id === currentChapter.id) { + return readingScopeId(currentChapter, anchorPart); + } + const firstItem = chapterItems(currentChapter)[0] || null; + if (firstItem) return readingScopeId(currentChapter, firstItem); + return readingScopeId(currentChapter); + } + return ""; +} + function chapterIsExpanded(chapter) { if (!chapter) return false; return expandedChapters.has(chapter.id); @@ -553,10 +1095,6 @@ function chapterKind(chapter) { return chapter && chapter.kind === "image" ? "image" : "text"; } -function isImageChapter(chapter) { - return chapterKind(chapter) === "image"; -} - function partRows(part) { if (!part) return []; const ids = new Set(part.row_ids || []); @@ -1098,6 +1636,7 @@ function renderImageChapter(chapter) { renderMixedChapterImages(chapter, items); return; } + const imageScopeItem = items[0] || null; const images = chapterImages(chapter); dom.chapterKicker.textContent = chapter.file_label || "插图"; dom.readerTitle.textContent = chapter.title || "插图"; @@ -1110,6 +1649,7 @@ function renderImageChapter(chapter) { images.forEach((image, index) => { const figure = document.createElement("figure"); figure.className = "imageChapterFigure"; + figure.dataset.readingScopeId = imageScopeItem ? readingScopeId(chapter, imageScopeItem) : readingScopeId(chapter); figure.innerHTML = ` ${escapeHtml(image.alt || chapter.title ||
@@ -1127,6 +1667,8 @@ function createImageFigure(item) { (item.images || []).forEach((image, index) => { const figure = document.createElement("figure"); figure.className = "imageChapterFigure inlineImageFigure"; + const parentChapter = chapterById(item.parent_chapter_id) || currentChapter; + if (parentChapter) figure.dataset.readingScopeId = readingScopeId(parentChapter, item); figure.innerHTML = ` ${escapeHtml(image.alt || item.title ||
@@ -1173,6 +1715,8 @@ function appendReadBlock(frag, row) { const block = document.createElement("section"); block.className = "readBlock"; block.dataset.rowId = row.id; + const part = partForRow(row); + if (part) block.dataset.readingScopeId = readingScopeId(chapterForPart(part), part); if (currentRow && row.id === currentRow.id) block.classList.add("active"); if (rowTouched(row)) block.classList.add("touched"); if (row.marked) block.classList.add("marked"); @@ -1277,16 +1821,34 @@ function fillFullEditor(row) { } function fillQuickEditor(row) { - dom.quickRowId.textContent = row.id; - dom.quickRowPath.textContent = `${row.file_label} · #${row.file_row_index || row.cn_p_index}`; - dom.quickJpText.innerHTML = row.jp_html || escapeHtml(row.jp_text); - dom.quickCnEditor.innerHTML = row.current_html || row.cn_html || ""; - dom.quickMarkedInput.checked = Boolean(row.marked); - dom.quickIssueTypeInput.value = row.issue_type || ""; - dom.quickSeverityInput.value = row.severity || ""; - dom.quickTagsInput.value = row.tags || ""; - dom.quickCommentInput.value = row.comment || ""; - dom.quickLearnNoteInput.value = row.learn_note || ""; + const hasRow = Boolean(row); + dom.quickRowId.textContent = hasRow ? row.id : "未选择段落"; + dom.quickRowPath.textContent = hasRow ? `${row.file_label} · #${row.file_row_index || row.cn_p_index}` : "可先配置 AI、术语表或执行反馈与交付操作"; + dom.quickJpText.innerHTML = hasRow ? row.jp_html || escapeHtml(row.jp_text) : '
在阅读区点击段落后,这里会显示日文原文。
'; + dom.quickCnEditor.innerHTML = hasRow ? row.current_html || row.cn_html || "" : ""; + dom.quickMarkedInput.checked = hasRow ? Boolean(row.marked) : false; + dom.quickIssueTypeInput.value = hasRow ? row.issue_type || "" : ""; + dom.quickSeverityInput.value = hasRow ? row.severity || "" : ""; + dom.quickTagsInput.value = hasRow ? row.tags || "" : ""; + dom.quickCommentInput.value = hasRow ? row.comment || "" : ""; + dom.quickLearnNoteInput.value = hasRow ? row.learn_note || "" : ""; + [ + dom.quickCnEditor, + dom.quickMarkedInput, + dom.quickIssueTypeInput, + dom.quickSeverityInput, + dom.quickTagsInput, + dom.quickCommentInput, + dom.quickLearnNoteInput, + dom.quickMarkSelectionBtn, + dom.quickSaveBtn, + dom.quickOpenFullBtn, + dom.gptInstructionInput, + dom.retranslateBtn, + ].forEach((el) => { + if (el) el.disabled = !hasRow; + if (el === dom.quickCnEditor) el.contentEditable = hasRow ? "true" : "false"; + }); gptCandidateHtml = ""; dom.gptCandidate.hidden = true; dom.gptCandidate.innerHTML = ""; @@ -1296,7 +1858,7 @@ function fillQuickEditor(row) { function collectCurrent() { if (!currentRow) return null; - if (currentMode === "reading" && !dom.quickEditor.hidden) { + if (!dom.quickEditor.hidden && (currentMode === "reading" || quickDirty)) { return collectQuick(); } return collectFull(); @@ -1370,7 +1932,7 @@ async function saveCurrent(silent = false) { async function maybeSaveBeforeSwitch() { await flushReadingPosition(); - if (currentMode === "reading" && quickDirty && currentRow && !dom.quickEditor.hidden) { + if (quickDirty && currentRow && !dom.quickEditor.hidden) { return saveCurrent(true); } if (currentMode === "polish" && dirty && currentRow) { @@ -1419,7 +1981,7 @@ async function selectChapter(chapter) { if (isImageChapter(chapter)) { currentRow = null; currentIndex = -1; - closeQuickEditor(false); + await closeQuickEditor(true); currentMode = "reading"; } else { const visible = searchScope === "book" ? chapterVisibleRowsInFilter(chapter) : filteredRows; @@ -1465,7 +2027,7 @@ async function selectImageItem(chapter, item) { currentIndex = -1; if (chapter) expandedChapters.add(chapter.id); applyFilter(false); - closeQuickEditor(false); + await closeQuickEditor(true); currentMode = "reading"; renderAll(); dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); @@ -1495,7 +2057,7 @@ async function setMode(mode, saveBefore = true) { currentPart = null; } if (mode === "polish") { - closeQuickEditor(false); + await closeQuickEditor(true); if (!scopeCoversRow(currentRow)) currentRow = null; if (!currentRow) currentRow = visibleRowsForScope()[0] || scopeRows()[0] || filteredRows[0] || null; } @@ -1506,29 +2068,37 @@ async function setMode(mode, saveBefore = true) { function openQuickEditor(row) { withReaderAnchor(() => { - currentRow = row; - if (isImageChapter(currentChapter) && !currentPart) currentChapter = null; - currentImageItem = null; - syncScopeToRow(row); - applyFilter(false); - currentIndex = filteredRows.findIndex((item) => item.id === row.id); + if (row) { + currentRow = row; + if (isImageChapter(currentChapter) && !currentPart) currentChapter = null; + currentImageItem = null; + syncScopeToRow(row); + applyFilter(false); + currentIndex = filteredRows.findIndex((item) => item.id === row.id); + } fillQuickEditor(row); dom.quickEditor.hidden = false; document.body.classList.add("quickOpen"); + dom.reviewToolsBtn.classList.add("active"); renderAll(); }); loadGptConfig().catch(() => {}); saveReadingPositionSoon(); } -function closeQuickEditor(save = false) { - if (save && quickDirty) { - saveCurrent(true); - return; +async function closeQuickEditor(save = false) { + if (quickDirty && currentRow) { + if (save) { + const ok = await saveCurrent(true); + if (!ok) return; + } else if (!window.confirm("当前段有未保存修改,关闭审校工具会放弃这些修改。继续吗?")) { + return; + } } withReaderAnchor(() => { dom.quickEditor.hidden = true; document.body.classList.remove("quickOpen"); + dom.reviewToolsBtn.classList.remove("active"); quickDirty = false; renderAll(); }); @@ -1558,6 +2128,24 @@ function renderGptConfig(config) { dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。"; } +function renderTranslationGptConfig(config) { + if (!dom.translationBaseUrlInput) return; + dom.translationBaseUrlInput.value = config.base_url || "https://api.openai.com/v1"; + dom.translationModelInput.value = config.model || "gpt-4o-mini"; + dom.translationApiKeyInput.value = ""; + dom.translationGlossaryPathInput.value = config.glossary_path || ""; + dom.translationPromptInput.value = config.translation_prompt || (config.prompt_defaults && config.prompt_defaults.translation_prompt) || ""; + dom.translationFormatPromptInput.value = config.format_prompt || (config.prompt_defaults && config.prompt_defaults.format_prompt) || ""; + dom.translationCharacterPromptInput.value = config.character_prompt || (config.prompt_defaults && config.prompt_defaults.character_prompt) || ""; + translationPromptDefaults = { + translation_prompt: (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "", + format_prompt: (config.prompt_defaults && config.prompt_defaults.format_prompt) || "", + character_prompt: (config.prompt_defaults && config.prompt_defaults.character_prompt) || "", + }; + const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置"; + dom.translationGptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key。"; +} + async function loadGptConfig(force = false) { if (gptConfigLoaded && !force) { if (!glossaryLoaded) loadGlossary(false).catch(() => {}); @@ -1594,12 +2182,46 @@ async function saveGptConfig() { gptConfigLoaded = true; glossaryLoaded = false; await loadGlossary(true); - toast("GPT 提示词与 API 设置已保存。"); + toast("AI 设置已保存。"); } catch (error) { toast(`保存 GPT 设置失败:${error.message}`, 7000); } } +async function saveTranslationGptConfig() { + try { + const config = await api("/api/gpt/config", { + method: "POST", + body: JSON.stringify({ + base_url: dom.translationBaseUrlInput.value, + model: dom.translationModelInput.value, + api_key: dom.translationApiKeyInput.value, + glossary_path: dom.translationGlossaryPathInput.value, + translation_prompt: dom.translationPromptInput.value, + format_prompt: dom.translationFormatPromptInput.value, + character_prompt: dom.translationCharacterPromptInput.value, + keep_existing: true, + }), + }); + renderTranslationGptConfig(config); + renderGptConfig(config); + gptConfigLoaded = true; + glossaryLoaded = false; + toast("AI 设置已保存,整书翻译会使用这套配置。"); + return config; + } catch (error) { + toast(`保存 API 设置失败:${error.message}`, 7000); + throw error; + } +} + +function resetTranslationPrompts() { + dom.translationPromptInput.value = translationPromptDefaults.translation_prompt || ""; + dom.translationFormatPromptInput.value = translationPromptDefaults.format_prompt || ""; + dom.translationCharacterPromptInput.value = translationPromptDefaults.character_prompt || ""; + toast("已恢复翻译页预设提示词。"); +} + function glossaryFilteredEntries() { const q = (dom.glossarySearchInput.value || "").trim().toLowerCase(); if (!q) return glossaryEntries.map((entry, index) => ({ entry, index })); @@ -1826,13 +2448,20 @@ async function go(delta) { } async function goPart(delta) { - const scopes = allChapterScopes(); + const scopes = allReadingScopes(); if (!scopes.length) return; - const currentScopeId = activeChapterScopeId(); + const currentScopeId = activeReadingScopeId(); const index = Math.max(0, scopes.findIndex((scope) => scope.id === currentScopeId)); const next = Math.max(0, Math.min(scopes.length - 1, index + delta)); if (next === index) return; - await selectChapter(scopes[next].chapter); + const scope = scopes[next]; + if (scope.type === "part") { + await selectPart(scope.part); + } else if (scope.type === "image") { + await selectImageItem(scope.chapter, scope.item); + } else { + await selectChapter(scope.chapter); + } } async function generateFeedback() { @@ -1858,6 +2487,10 @@ async function exportEpub() { } function initEvents() { + readStoredReaderSettings(); + applyReaderSettings(false); + initChromeMetrics(); + [ dom.markedInput, dom.cnEditor, @@ -1904,7 +2537,20 @@ function initEvents() { openSidePanel("search"); } }); + dom.reviewToolsBtn.addEventListener("click", async () => { + if (!hasSession) return; + if (!dom.quickEditor.hidden) { + await closeQuickEditor(false); + return; + } + const ok = await maybeSaveBeforeSwitch(); + if (!ok) return; + openQuickEditor(null); + }); dom.closeSidebarBtn.addEventListener("click", closeSidePanel); + dom.bookshelfBtn.addEventListener("click", () => { + showBookshelf().catch((error) => toast(`打开书架失败:${error.message}`, 7000)); + }); dom.showTouchedBtn.addEventListener("click", () => { showTouchedOnly = !showTouchedOnly; dom.showTouchedBtn.classList.toggle("primary", showTouchedOnly); @@ -1915,9 +2561,18 @@ function initEvents() { dom.readingModeBtn.addEventListener("click", () => setMode("reading")); dom.polishModeBtn.addEventListener("click", () => setMode("polish")); dom.openLibraryBtn.addEventListener("click", async () => { - if (hasSession && !(await maybeSaveBeforeSwitch())) return; + if (!(await leaveReviewShell())) return; await showStartScreen(); }); + [ + dom.themeDefaultBtn, + dom.themeEyeBtn, + dom.themeNightBtn, + ].forEach((button) => { + button.addEventListener("click", () => setReaderTheme(button.dataset.readerTheme)); + }); + dom.readerFontSizeInput.addEventListener("input", () => setReaderFontSize(dom.readerFontSizeInput.value)); + dom.readerLineHeightInput.addEventListener("input", () => setReaderLineHeight(dom.readerLineHeightInput.value)); dom.prevPartBtn.addEventListener("click", () => goPart(-1)); dom.nextPartBtn.addEventListener("click", () => goPart(1)); dom.resetTextBtn.addEventListener("click", resetCurrentText); @@ -1937,15 +2592,33 @@ function initEvents() { dom.refreshSessionsBtn.addEventListener("click", () => { refreshSessions().catch((error) => toast(`刷新失败:${error.message}`, 7000)); }); - dom.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false)); + dom.bookshelfRefreshBtn.addEventListener("click", () => { + refreshBookshelf().catch((error) => toast(`刷新书架失败:${error.message}`, 7000)); + }); + dom.bookshelfOpenUploadBtn.addEventListener("click", async () => { + if (!(await leaveReviewShell())) return; + await showStartScreen(); + }); + dom.translationBackBookshelfBtn.addEventListener("click", () => { + showBookshelf().catch((error) => toast(`返回书架失败:${error.message}`, 7000)); + }); + dom.translationOpenReviewBtn.addEventListener("click", () => { + if (!translationOutputSessionId) return; + openSession(translationOutputSessionId).catch((error) => toast(`打开输出审校失败:${error.message}`, 7000)); + }); + dom.translationSaveConfigBtn.addEventListener("click", () => { + saveTranslationGptConfig().catch(() => {}); + }); + dom.translationResetPromptsBtn.addEventListener("click", resetTranslationPrompts); + dom.translationForm.addEventListener("submit", startTranslationJob); + dom.translationRangeModeInput.addEventListener("change", () => { + dom.translationLimitInput.disabled = dom.translationRangeModeInput.value === "all"; + }); + dom.closeQuickEditorBtn.addEventListener("click", () => { + closeQuickEditor(false).catch((error) => toast(`关闭失败:${error.message}`, 7000)); + }); dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false)); dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection); - dom.toggleGptConfigBtn.addEventListener("click", () => { - dom.gptConfig.hidden = !dom.gptConfig.hidden; - if (!dom.gptConfig.hidden) { - loadGptConfig(true).catch(() => {}); - } - }); dom.saveGptConfigBtn.addEventListener("click", saveGptConfig); dom.resetGptPromptsBtn.addEventListener("click", resetGptPrompts); dom.reloadGlossaryBtn.addEventListener("click", () => { @@ -2006,7 +2679,7 @@ function initEvents() { } if (event.key === "Escape" && !dom.quickEditor.hidden) { event.preventDefault(); - closeQuickEditor(false); + closeQuickEditor(false).catch((error) => toast(`关闭失败:${error.message}`, 7000)); return; } if (event.key === "Escape" && sidePanelMode) { @@ -2015,6 +2688,12 @@ function initEvents() { } }); + document.addEventListener("mousemove", updateChromeVisibility, { passive: true }); + document.addEventListener("focusin", (event) => { + if (event.target && (event.target.closest(".topbar") || event.target.closest(".readerHeader"))) { + showChromeBriefly(); + } + }); window.addEventListener("beforeunload", (event) => { sendReadingPositionBeacon(); if (!dirty && !quickDirty) return; diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index e25649b..ff8ae36 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -4,12 +4,13 @@ 双语 EPUB 审校编辑器 - +
+
-

双语 EPUB 审校编辑器 v0.8.0

+

双语 EPUB 审校编辑器 v0.12.2

正在载入……

@@ -20,13 +21,141 @@ - - - - +
+
+
+
+
+

书架

+

这里列出这个审校目录里历史打开过的 EPUB。点击一本书即可回到对应审校页面。

+
+
+ + +
+
+
正在读取书架……
+
+
+
+ +
+
+
+
+

AI 翻译 EPUB

+

选择书架中的日语 EPUB 后,可在这里设置提示词、术语表、范围和输出格式。

+
+
+ + +
+
+ +
+
+
+
+

输出与范围

+ 未读取源书信息 +
+
+ + + + +
+
+
+ +
+
+

API 与术语表

+ 未读取配置 +
+
+ + + + +
+
+ +
+
+ +
+
+

提示词

+ +
+ + + +
+ +
+
+
+ + +
+
+
+
@@ -92,8 +221,25 @@

选择章节开始阅读

- - +
+
+ + + +
+ + +
+ +
@@ -168,7 +314,7 @@
@@ -181,29 +327,81 @@
-