diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6b6b36..1243322 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,12 @@
+## v0.14 (2026-10-08) — Chinese covers and library edition metadata
+
+- Add source-bound bibliographic configuration with authoritative/project title status and evidence; include verified Gambling Man title/author and a clearly provisional House of Huawei title.
+- Prepare image-generation jobs and package separately reviewed Chinese/bilingual covers without retranslating text or adding runtime dependencies.
+- Distinguish modes and revisions in library titles, cover badges and edition UUIDs; retain original bibliographic provenance without borrowing official Chinese publication identity.
+- Update EPUB2/3 cover metadata and XHTML/SVG bindings; clone external SVG wrappers, retain original image/font bytes and prevent cover stretching.
+- Integrate optional edition configuration into API and complete-book agent builds; extend headless checks to title, author, language, identifier, cover hash and document bindings.
+- Extend translation skill with bibliographic research, image generation/review and offline packaging. Codex built-in imagegen verified; exact image model remains undisclosed, OAuth API image generation is currently unsupported.
+
## 2026-10-08 — translation skill and desktop-independent QA
- Add discoverable `epub-translate` skill for full-book agent/API translation, terminology review, selective repair and evidence-based acceptance.
diff --git a/README.md b/README.md
index 8be0abe..54fefe5 100644
--- a/README.md
+++ b/README.md
@@ -110,3 +110,42 @@ Mac 沙箱中 Calibre 的 Qt 渲染可能需要允许进程运行。QA 使用项
无法可靠绑定的目录条目仅翻译标签,不改写猜测的章节正文;具体条目记录在 `book_structure.json` 的 `navigation_titles[].binding_note`。这类条目需要检查章节标题是否一致,不能视为全书标题同步已经通过。索引、版权、注释全文仍遵守原有跳过策略;导航中这些页面的名称可以翻译。
后续扫描 PDF/OCR 大版本的图文、结构与目录方案见 [路线图](doc/OCR_EPUB_ROADMAP.md)。
+
+### 中文封面与书库 metadata(v0.14)
+
+新增可选的版本封装步骤,可给已有完整译本补中文封面,无需再调用正文翻译模型。书库标题采用 `中文书名【中文版 · r1】`/`中文书名【中英对照版 · r1】`;封面也必须显示对应版本与修订号。两版使用不同 UUID,原作标识转存 `dc:source`,避免沿用英文版 ISBN/Calibre UUID。原作标题、作者、出版社、日期与生图来源保存在 OPF 的 edition 记录中。本文件标为 AI 译本,不冒用正式中文出版版的译者、出版社或 ISBN。
+
+1. 在 edition JSON 固定原作标题/作者(可附标识符)、中文书名、`title_status`、证据网址和 `edition_revision`。正式出版译名优先出版社/国家图书馆记录;找不到时使用 `project`,不得标成权威。`config/editions/gambling-man.json` 采用商周出版译名;`house-of-huawei.json` 暂为项目译名。繁简转换或删改正式译名须另作说明。
+2. 提取参考封面和生图任务:
+
+ ```bash
+ .venv/bin/python scripts/localize_edition.py prepare input/book.epub \
+ --config config/editions/book.json --mode target_only --work .work/book-cover/chinese
+ ```
+
+3. 由 `epub-translate` skill 调用当前可用生图工具,编辑原封面,另存 PNG/JPEG。分别制作中文版与双语版,版本徽标用朱红/深蓝区分。逐字核对书名、作者、版本徽标和修订号,检查全尺寸与书库缩略图。审查记录须包含实际图片 SHA,示例:
+
+ ```json
+ {"cover_sha256":"图片文件的SHA256", "mode":"target_only", "title_zh":"中文书名", "edition_revision":"r1", "visual_review":"passed", "provider":"Codex built-in imagegen", "model":null, "review_notes":"已逐字查看书名、作者及版本徽标"}
+ ```
+
+4. 对**已有译本**封装并审计:
+
+ ```bash
+ .venv/bin/python scripts/localize_edition.py apply output/translated_book.epub \
+ --config config/editions/book.json --mode target_only \
+ --cover .work/book-cover/chinese/cover.png --review .work/book-cover/chinese/review.json \
+ --output output/editions/translated_book_r1.epub --work .work/book-cover/chinese
+ ```
+
+ 原始图片/字体字节保留;新增封面更新 EPUB2 `meta name=cover`、EPUB3 `cover-image` 和 XHTML/SVG 引用;外置 SVG 包装器另存副本。没有封面时新增首个 spine 封面页。修正封面 SVG 强制拉伸,保持图片比例。目录和正文插图保持原样。`edition-qa.json` 包含原/新版差异;headless QA 继续检查封面绑定、图片摘要、metadata、目录和正文完整度。
+
+自动流水线可给 `main.py` 或 `scripts/agent_book.py build` 加 `--edition-config config/editions/book.json`。在 JSON 中另加 `covers`:
+
+```json
+{"covers":{"target_only":{"image":"../../.work/book-cover/chinese/cover.png","review":"../../.work/book-cover/chinese/review.json"},"bilingual":{"image":"../../.work/book-cover/bilingual/cover.png","review":"../../.work/book-cover/bilingual/review.json"}}}
+```
+
+路径相对于 JSON 文件;常规译本保留,另生成 `edition_*.epub`。缺少已审封面会报错,不自动使用另一个版本的图片。基础封装不新增依赖、无网络请求;生图和书目检索由 skill 编排。
+
+2026-10-08 接入核实:本机 Codex 使用 ChatGPT 登录,缓存目录没有可选择的 image 模型。官方模型目录提供 `gpt-image-2.5-sunburst`/`gpt-image-2.5-flare`,但[ChatGPT OAuth API 路径暂不支持图片生成](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations)。本轮实际使用 Codex 内置 imagegen 成功生成两版;该工具不公开模型 ID/选择参数,因此记录 `model: null`,不能宣称使用 GPT-Image-2.5。后续运行先检查当时的官方支持情况和工具能力;不要读取或转用私有 OAuth token 绕过限制,也不自动改用付费 API。
diff --git a/config/editions/gambling-man.json b/config/editions/gambling-man.json
new file mode 100644
index 0000000..bde8072
--- /dev/null
+++ b/config/editions/gambling-man.json
@@ -0,0 +1,29 @@
+{
+ "source": {
+ "titles": [
+ "Gambling Man",
+ "The Secret Story of the World's Greatest Disruptor, Masayoshi Son"
+ ],
+ "authors": [
+ "Lionel Barber"
+ ],
+ "identifier": "9781668070765"
+ },
+ "title_zh": "商業狂人孫正義:軟銀創辦人顛覆全球科技與金融版圖的傳奇人生",
+ "authors_zh": [
+ "萊奧納.巴伯"
+ ],
+ "title_status": "authoritative",
+ "evidence": [
+ {
+ "url": "https://www.cite.com.tw/book?id=105687",
+ "publisher": "商周出版",
+ "edition_isbn": "9786263907379",
+ "checked": "2026-10-08",
+ "note": "Chinese title/author reference only; the generated AI translation is not the published Chinese translation. Different English edition subtitle verified as the same Lionel Barber work."
+ }
+ ],
+ "edition_revision": "r1",
+ "cover_title_zh": "商業狂人孫正義",
+ "cover_subtitle_zh": "軟銀創辦人顛覆全球科技與金融版圖的傳奇人生"
+}
diff --git a/config/editions/house-of-huawei.json b/config/editions/house-of-huawei.json
new file mode 100644
index 0000000..0505190
--- /dev/null
+++ b/config/editions/house-of-huawei.json
@@ -0,0 +1,17 @@
+{
+ "source": {
+ "titles": [
+ "House of Huawei: The Secret History of China's Most Powerful Company"
+ ],
+ "authors": [
+ "Eva Dou"
+ ]
+ },
+ "title_zh": "华为之屋:中国最强大公司的秘史",
+ "title_status": "project",
+ "evidence": [],
+ "edition_revision": "r1",
+ "note": "No authoritative Chinese edition confirmed as of 2026-10-08; keep Eva Dou's author credit. Replace this project title when a publisher/library record is verified.",
+ "cover_title_zh": "华为之屋",
+ "cover_subtitle_zh": "中国最强大公司的秘史"
+}
diff --git a/doc/QA_COVER_METADATA_2026-10-08.md b/doc/QA_COVER_METADATA_2026-10-08.md
new file mode 100644
index 0000000..6c2eab6
--- /dev/null
+++ b/doc/QA_COVER_METADATA_2026-10-08.md
@@ -0,0 +1,33 @@
+# v0.14 封面/书库 metadata 验证
+
+当前翻译改进分支已先合并并推送至 main(8297e6d)。随后独立实现中文封面与版本 metadata。
+
+## 生图接入
+
+本机 `codex login status` 确认为 ChatGPT 登录。models_cache 中只有 10 个条目,没有 image 模型 slug;缓存列表不能作为权限结论。官方目录提供 GPT-Image-2.5 的 sunburst/flare 型号,但 [ChatGPT OAuth API 路径](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations) 当前不支持图片生成。实际使用本会话内置 imagegen 生成中文版、双语版及蓝色双语徽标修订,均成功;工具不公开具体模型 ID/选择参数,来源记录为 `Codex built-in imagegen`、`model: null`。没有访问私有 OAuth token,也没有新增付费 API 配置。
+
+## 书目依据
+
+- Gambling Man:已匹配 [商周出版/城邦书目](https://www.cite.com.tw/book?id=105687),主标题《商業狂人孫正義》,副标题“軟銀創辦人顛覆全球科技與金融版圖的傳奇人生”,作者“萊奧納.巴伯”。中文出版 ISBN 9786263907379 仅用于书目证据,不写作本 AI 译本的标识符;不使用正式译者的署名。源书为美国版,英文副标题不同,作者及作品章节相匹配。
+- House of Huawei:未确认正式中文出版书目,采用项目译名《华为之屋:中国最强大公司的秘史》,保留 Eva Dou。配置与 OPF 均标为 project,不以搜索不到证明中文版本不存在。
+
+## 确定性回归范围
+
+最终完整回归:`82 passed`(启用 Chromium 集成检查)。
+
+测试覆盖 EPUB2/3 cover metadata、普通图片/内嵌 SVG 封面、外置 SVG 包装器副本、无封面首个 spine 页面、独立版本 UUID、原 ISBN 的 source 保存、作者/语言、清除旧标题及作者 refinement、图片格式识别、保留正文插图和原封面字节、配置相对路径、拒绝错书/未审图片/重复封装/源覆盖及检测图片损坏。
+
+额外启用真实 Chromium UTF-8/注号点击集成检查,以及原书无封面时新增封面页的渲染检查(无原文对应页时只显示译版证据,不放宽其他缺页检查)。基础封装未增加依赖;可选浏览器仍使用已有 Playwright + Chromium headless shell。
+
+## 真实完整译本验证
+
+对已有完整 House of Huawei 的两个译本补封面,正文沿用原 Sonnet 5.5 译文,不发起新的正文翻译请求。输出:
+
+- `output/editions/translated_华为之屋_r1.epub`
+- `output/editions/bilingual_华为之屋_r1.epub`
+
+新 metadata 标题分别含“中文版 · r1”和“中英对照版 · r1”,具有独立 UUID。封面中文版为朱红徽标、双语版为深蓝徽标。检查原书到新版全书清单:2837/2837 有效单元、源匹配、0 个新增工程问题;所有原始图片/字体字节仍保留。外部尾注仍属于原书提供的网站入口,没有引入书内尾注或改变注号目标。
+
+使用 Chromium 在两版实际 EPUB 的 `titlepage.xhtml` 渲染 900×1200 和 180×270 证据。先发现原书 SVG `preserveAspectRatio=none` 导致封面拉伸,修为 `xMidYMid meet` 后重新查看。主标题、副标题、EVA DOU、版本徽标和修订号在全尺寸均完整;缩略图可辨认主标题,两版徽标颜色明显不同。小缩略图中的副标题、作者长说明及修订号仍较小,书库 metadata 同时提供明确版本文字。
+
+证据留在 `.work/edition/House of Huawei/qa-chinese`、`qa-bilingual`、`thumbnails` 及各模式 review 文件。report 的 visual/semantic pending 保持原值,实际视觉审查另记 review-record.json。自动封面 hash/metadata 正确并不等于生图文字或全书语义审校通过。本轮仅扩大封面视觉验证,未重新做全书逐页语义审校,也未声称所有阅读器分页完全一致。
diff --git a/main.py b/main.py
index ea1fb3f..0004a6e 100644
--- a/main.py
+++ b/main.py
@@ -40,6 +40,9 @@ from src.common.paths import get_work_dirs
async def run_pipeline(args):
input_path = Path(args.input_epub)
output_dir = Path(args.output_dir)
+ if getattr(args, 'edition_config', None):
+ from src.edition.localize import validate_config_assets
+ validate_config_assets(input_path, args.edition_config, args.mode)
# Get work directories for this book
work = get_work_dirs(input_path)
@@ -164,6 +167,9 @@ async def run_pipeline(args):
output_path = output_dir / output_filename
created_epub = builder.build(updated_structure, output_path)
+ if getattr(args, 'edition_config', None):
+ from src.edition.localize import localize_from_config
+ created_epub = localize_from_config(created_epub, args.edition_config, args.mode)
from src.qa.epub_audit import audit_epub, compare_audits
source_audit, output_audit = audit_epub(input_path), audit_epub(created_epub)
@@ -199,6 +205,7 @@ def main():
parser.add_argument("input_epub", help="Path to the input EPUB file")
parser.add_argument("--output-dir", default="output", help="Directory for output files")
parser.add_argument("--visual-qa", action="store_true", help="Render Calibre QA page images for visual inspection")
+ parser.add_argument("--edition-config", help="Source-bound Chinese title and reviewed cover JSON")
parser.add_argument("--glossary", help="Per-book JSON terminology file")
parser.add_argument("--config", default="config/config.yaml", help="Local YAML configuration file")
parser.add_argument("--env-file", help="Local credential dotenv file (never committed)")
diff --git a/scripts/agent_book.py b/scripts/agent_book.py
index 94e04e4..d49ac4f 100644
--- a/scripts/agent_book.py
+++ b/scripts/agent_book.py
@@ -74,6 +74,10 @@ async def run(args):
'total': len(entries), 'pending_batches': len(batches), 'batch_files': [f'{i:04d}.json' for i in range(len(batches))]})
print(f'Prepared {len(entries)} complete-book units; {len(batches)} pending batches; {work}')
return
+ if args.action == 'build' and getattr(args, 'edition_config', None):
+ from src.edition.localize import validate_config_assets
+ for mode in ('target_only', 'bilingual'):
+ validate_config_assets(args.source, args.edition_config, mode)
structure = verify_source(args.source, work)
metadata = json.loads((work/'agent-run.json').read_text())
if metadata['model'] != args.model:
@@ -114,7 +118,10 @@ async def run(args):
for mode, prefix in [('target_only', 'translated_'), ('bilingual', 'bilingual_')]:
copy = structure.model_copy(deep=True)
await BackfillEngine().backfill(copy, entries, mode=mode)
- BilingualBuilder(work, args.source).build(copy, output/(prefix+args.source.name))
+ built = BilingualBuilder(work, args.source).build(copy, output/(prefix+args.source.name))
+ if getattr(args, 'edition_config', None):
+ from src.edition.localize import localize_from_config
+ localize_from_config(built, args.edition_config, mode)
save(work/'translation-provenance.json', {'model': args.model, 'source_sha256': structure.source_sha256,
'total': len(entries), 'accepted': len(entries), 'provider': 'agent-authored manifest'})
print(f'Built both full-book modes: {len(entries)}/{len(entries)}; {output}')
@@ -124,6 +131,7 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['prepare', 'apply', 'build']); parser.add_argument('source', type=Path)
parser.add_argument('--work', required=True, type=Path); parser.add_argument('--result', type=Path)
+ parser.add_argument('--edition-config', type=Path)
parser.add_argument('--glossary', type=Path); parser.add_argument('--output-dir', type=Path)
parser.add_argument('--model', default='agent/luna'); parser.add_argument('--batch-chars', type=int, default=16000)
args = parser.parse_args()
diff --git a/scripts/localize_edition.py b/scripts/localize_edition.py
new file mode 100644
index 0000000..f03a5a6
--- /dev/null
+++ b/scripts/localize_edition.py
@@ -0,0 +1,35 @@
+"""Prepare a cover-generation job, then package a reviewed image and Chinese metadata."""
+import argparse
+import json
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from src.edition.localize import prepare, localize, audit_edition
+from src.qa.headless import audit_pair
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('action', choices=['prepare', 'apply'])
+ parser.add_argument('input', type=Path)
+ parser.add_argument('--config', required=True, type=Path)
+ parser.add_argument('--mode', required=True, choices=['target_only', 'bilingual'])
+ parser.add_argument('--work', type=Path, default=Path('.work/edition'))
+ parser.add_argument('--output', type=Path)
+ parser.add_argument('--cover', type=Path)
+ parser.add_argument('--review', type=Path)
+ args = parser.parse_args()
+ if args.action == 'prepare':
+ print(json.dumps(prepare(args.input, args.config, args.mode, args.work), ensure_ascii=False, indent=2))
+ return 0
+ if not all([args.output, args.cover, args.review]):
+ parser.error('apply needs --output, --cover and --review')
+ record = localize(args.input, args.output, args.config, args.mode, args.cover, args.review)
+ report, _, _ = audit_pair(args.input, args.output)
+ report['edition'] = audit_edition(args.output)
+ args.work.mkdir(parents=True, exist_ok=True)
+ (args.work/'edition-qa.json').write_text(json.dumps(report, ensure_ascii=False, indent=2))
+ print(json.dumps({'output': str(args.output), 'title': record['title'], 'issues': report['new_issues']}, ensure_ascii=False))
+ return 1 if report['new_issues'] else 0
+
+if __name__ == '__main__': sys.exit(main())
diff --git a/skills/epub-translate/SKILL.md b/skills/epub-translate/SKILL.md
index b619724..c539165 100644
--- a/skills/epub-translate/SKILL.md
+++ b/skills/epub-translate/SKILL.md
@@ -38,3 +38,9 @@ description: Translate complete EPUB books and iteratively review terminology, n
有证据的失败修复后重建两版并重跑受影响检查;连续出现相同失败且没有新证据时保留待审记录,避免重复付费请求;有明确修复线索时继续定位和验证。不把 Chromium 连续 XHTML 排版描述为所有阅读器的分页效果。
交付实际 EPUB 链接、模型/来源记录、工程检查结果、已审截图和语义抽查范围,以及未解决项。只有全部提取单元有效并完整回填时才称“完整译本”;没有全书逐页审校时使用“抽样 QA”。书籍、密钥和 `.work` 数据留在本地,代码及 skill 的推送沿用用户已有授权。
+
+## 中文封面与书库版本
+
+需要中文封面/metadata 时按 [执行流程](references/workflow.md) 的版本封装步骤处理。先查出版社/国家图书馆书目,核对原作和作者;没有正式译名则标为项目译名。不得将发布版的 ISBN、译者或出版社冒用到 AI 译本。两版的封面徽标、metadata 模式和修订号必须一致。
+
+优先检查用户指定模型及当前 Codex 生图能力。内置工具可用但不公开模型时,记录提供方与 `model: null`,不能声称已选择 GPT-Image-2.5。图像文字是独立的生图审校范围;先查看原图,再编辑,逐字查看生成图与 headless 书内渲染,并用图片摘要绑定 review。只在确认正确后写 `visual_review: passed`。书库缩略图须能辨认书名和版本,旧封面/正文插图字节继续保留。SVG 参考图若工具不能直接读取,用 headless 浏览器渲染为参考 PNG。
diff --git a/skills/epub-translate/references/workflow.md b/skills/epub-translate/references/workflow.md
index ccb21c9..3746e26 100644
--- a/skills/epub-translate/references/workflow.md
+++ b/skills/epub-translate/references/workflow.md
@@ -75,3 +75,15 @@ Linux 系统运行库仍可能需要官方 `install --with-deps chromium --only-
退出 1 表示工程检查/完整度/注号点击失败;退出 2 表示所请求的渲染未成功,不以缺依赖降级成通过。`report.json` 保存源/输出 SHA、基线问题、覆盖范围、注释分类、布局提示和真实浏览器点击结果;`review.html` 和 PNG 是视觉证据。
查看截图后另写 `review-record.json`:已审文档/图片、发现项、修改与复验结果、语义抽查单元、剩余问题。原始自动报告的 visual/semantic 状态保持 pending,防止后续把机器生成证据误当人工验收。源码里不存在的网站尾注不在“完整源 EPUB 翻译”范围内;若用户要求纳入,单独获取、翻译并重建注号目标,不能猜注释内容。
+
+## 版本封装:封面和 metadata
+
+读取 README 的「中文封面与书库 metadata」步骤,使用 `scripts/localize_edition.py prepare/apply`。可在完整译本生成后单独封装,不重译正文。生图是 agent 的显式工具步骤,脚本只提取任务、验证已审图片、封装和审计;普通脚本不能假装拥有 Codex 的内置工具或私有 OAuth 生图权限。
+
+先核实 title/author 的正式中文出版书目;保存网址、查询日期、版次关联和 ISBN(仅作为证据)。输入 title/author/identifier 不匹配时修订配置,不放宽匹配。保留繁体正式书名,用户需要简体时记录转换,不能默默改成另一书名。研究不到的条目写 `title_status: project`。
+
+`prepare` 输出 cover-job.json 与参考封面。先查看参考图,用可用生图模型制作中文版和双语版,再查看输出图。review.json 记录实际 SHA、mode、title_zh、edition_revision、provider、model 和具体审查内容。模型 ID 不可见则为 null。错字、错误徽标、缺失修订号、裁切或缩略图不可读时迭代生图,不以 metadata 正确代替图片审校。不得借用另一部书的封面。
+
+`apply` 仅用于相应完整译本;分别指定 mode/图片/review/输出,保留原译本。`main.py` 和 agent build 的可选 `--edition-config` 通过配置 covers 自动调用同一封装步骤。重新修订使用未封装的译本作为输入,更新修订号和图片审查记录。
+
+运行原书到新版的 headless QA,并显式选择封面 XHTML(如 titlepage.xhtml)。查看全尺寸封面及书库缩略图,检查外置 SVG 或内嵌 SVG 是否拉伸。审计仍要求原图、字体、正文插图不变,并核验新的封面标识、摘要、实际 XHTML/SVG 引用、语言和作者。metadata 的已审书名不是全书语义验收。
diff --git a/src/edition/__init__.py b/src/edition/__init__.py
new file mode 100644
index 0000000..7c8f0ea
--- /dev/null
+++ b/src/edition/__init__.py
@@ -0,0 +1 @@
+"""Localize library-facing covers and metadata without retranslating the book."""
diff --git a/src/edition/localize.py b/src/edition/localize.py
new file mode 100644
index 0000000..8e7c243
--- /dev/null
+++ b/src/edition/localize.py
@@ -0,0 +1,346 @@
+"""Offline, source-bound edition packaging. Image generation belongs to the skill."""
+import hashlib
+import json
+import posixpath
+import uuid
+import zipfile
+from datetime import datetime, timezone
+from pathlib import Path
+from urllib.parse import unquote, urlsplit, quote
+from lxml import etree
+from src.assembly.package_writer import package_path, OPF, DC
+
+LABELS = {'target_only': '中文版', 'bilingual': '中英对照版'}
+PREFIX = 'https://kais.cloud/epub-translator/metadata/'
+RECORD_KEY = 'translation:edition'
+PARSER = lambda: etree.XMLParser(resolve_entities=False, no_network=True)
+
+
+def sha(data):
+ return hashlib.sha256(data).hexdigest()
+
+
+def archive_path(document, href):
+ url = urlsplit(href)
+ if url.scheme or url.netloc:
+ return None
+ return posixpath.normpath(posixpath.join(posixpath.dirname(document), unquote(url.path)))
+
+
+def read_package(path):
+ with zipfile.ZipFile(path) as z:
+ name = package_path(z)
+ return name, etree.fromstring(z.read(name), PARSER())
+
+
+def dc_values(root, key):
+ return [x.text or '' for x in root.findall(f'{{{OPF}}}metadata/{{{DC}}}{key}')]
+
+
+def load_config(path):
+ cfg = json.loads(Path(path).read_text())
+ if not cfg.get('title_zh') or cfg.get('title_status') not in {'authoritative', 'project'}:
+ raise ValueError('Need title_zh and title_status (authoritative/project)')
+ if cfg['title_status'] == 'authoritative' and not cfg.get('evidence'):
+ raise ValueError('Authoritative title requires publisher/library evidence URLs')
+ if not cfg.get('source', {}).get('titles') or not cfg['source'].get('authors'):
+ raise ValueError('Bind the edition to source titles and authors')
+ if not cfg.get('edition_revision'):
+ raise ValueError('Need edition_revision to distinguish translation revisions')
+ return cfg
+
+
+def verify_binding(root, cfg):
+ # Exact source matching prevents applying a researched title to another work.
+ if dc_values(root, 'title') != cfg['source']['titles'] or dc_values(root, 'creator') != cfg['source']['authors']:
+ raise ValueError('Edition config does not match source title/author')
+ expected = cfg['source'].get('identifier')
+ if expected and expected not in dc_values(root, 'identifier'):
+ raise ValueError('Edition config source identifier mismatch')
+
+
+def cover_item(root):
+ metadata = root.find(f'{{{OPF}}}metadata')
+ manifest = root.find(f'{{{OPF}}}manifest')
+ ids = {x.get('content') for x in metadata.findall(f'{{{OPF}}}meta') if x.get('name') == 'cover'}
+ images = [x for x in manifest if 'cover-image' in x.get('properties', '').split()]
+ if not images:
+ images = [x for x in manifest if x.get('id') in ids and x.get('media-type', '').startswith('image/')]
+ if len(images) > 1:
+ raise ValueError('Ambiguous cover image; repair source cover metadata first')
+ return images[0] if images else None
+
+
+def prepare(epub, config, mode, work):
+ cfg = load_config(config); opf_path, root = read_package(epub); verify_binding(root, cfg)
+ work = Path(work); work.mkdir(parents=True, exist_ok=True)
+ item = cover_item(root); reference = None
+ if item is not None:
+ with zipfile.ZipFile(epub) as z:
+ name = archive_path(opf_path, item.get('href'))
+ reference = work/('original-cover' + Path(name).suffix)
+ reference.write_bytes(z.read(name))
+ job = {'source_sha256': sha(Path(epub).read_bytes()), 'mode': mode,
+ 'title_zh': cfg['title_zh'], 'label': LABELS[mode],
+ 'reference': str(reference.resolve()) if reference else None,
+ 'prompt': f'Edit the supplied book cover (or create a restrained typographic cover if absent). Preserve its artwork and visual identity. Make the exact Chinese main title “{cfg.get("cover_title_zh", cfg["title_zh"])}” large and legible at library-thumbnail size. Use the exact Chinese subtitle “{cfg.get("cover_subtitle_zh", "")}” in smaller type. Keep the original English title smaller. Add a clearly visible badge with exactly “{LABELS[mode]} · AI译本” and a small revision “{cfg["edition_revision"]}”. Keep the author credit accurate. Do not invent a publisher, translator or official-edition claim. Render all Chinese characters precisely. Use a {"vermilion red" if mode == "target_only" else "navy blue"} version badge to distinguish the two modes at thumbnail size. Portrait cover, no mockup, no surrounding frame.'}
+ (work/'cover-job.json').write_text(json.dumps(job, ensure_ascii=False, indent=2))
+ return job
+
+
+def image_type(data):
+ if data.startswith(b'\x89PNG\r\n\x1a\n'):
+ return 'image/png', '.png'
+ if data.startswith(b'\xff\xd8\xff'):
+ return 'image/jpeg', '.jpg'
+ raise ValueError('Cover must be PNG or JPEG (format detected from bytes)')
+
+
+def remove_elements(metadata, elements):
+ ids = {x.get('id') for x in elements if x.get('id')}
+ while True:
+ related = [x for x in metadata if x.get('refines', '').lstrip('#') in ids]
+ expanded = ids | {x.get('id') for x in related if x.get('id')}
+ if expanded == ids: break
+ ids = expanded
+ for x in list(metadata):
+ if x in elements or x.get('refines', '').lstrip('#') in ids:
+ metadata.remove(x)
+
+
+def set_values(metadata, key, values):
+ remove_elements(metadata, list(metadata.findall(f'{{{DC}}}{key}')))
+ for value in values:
+ etree.SubElement(metadata, f'{{{DC}}}{key}').text = value
+
+
+def edition_record(root):
+ for x in root.findall(f'{{{OPF}}}metadata/{{{OPF}}}meta'):
+ if x.get('name') == RECORD_KEY or x.get('property') == RECORD_KEY:
+ return json.loads(x.get('content') if x.get('name') else x.text)
+ return None
+
+
+def reviewed_cover(cover, review, cfg, mode):
+ image = Path(cover).read_bytes(); mime, suffix = image_type(image)
+ reviewed = json.loads(Path(review).read_text())
+ if (reviewed.get('cover_sha256') != sha(image) or reviewed.get('mode') != mode or
+ reviewed.get('title_zh') != cfg['title_zh'] or reviewed.get('edition_revision') != cfg['edition_revision'] or
+ reviewed.get('visual_review') != 'passed' or not reviewed.get('provider')):
+ raise ValueError('Cover review missing, stale or for another title/mode/revision')
+ return image, mime, suffix, reviewed
+
+
+def localize(epub, output, config, mode, cover, review):
+ """Keep original assets; add one reviewed cover and retarget cover references."""
+ epub, output, cover = Path(epub), Path(output), Path(cover)
+ if mode not in LABELS:
+ raise ValueError('Unknown edition mode')
+ if output.resolve() == epub.resolve():
+ raise ValueError('Output must not overwrite input')
+ cfg = load_config(config); opf_path, root = read_package(epub)
+ if edition_record(root):
+ raise ValueError('Already localized; use the untranslated-metadata EPUB for a fresh revision')
+ verify_binding(root, cfg)
+ image, mime, suffix, reviewed = reviewed_cover(cover, review, cfg, mode)
+ metadata = root.find(f'{{{OPF}}}metadata'); manifest = root.find(f'{{{OPF}}}manifest')
+ original = {'titles': dc_values(root, 'title'), 'authors': dc_values(root, 'creator'),
+ 'identifiers': dc_values(root, 'identifier'), 'languages': dc_values(root, 'language'),
+ 'publishers': dc_values(root, 'publisher'), 'dates': dc_values(root, 'date')}
+ old_cover = cover_item(root)
+ old_path = archive_path(opf_path, old_cover.get('href')) if old_cover is not None else None
+ title = f'{cfg["title_zh"]}【{LABELS[mode]} · {cfg["edition_revision"]}】'
+ record = {'schema': 1, 'mode': mode, 'title': title, 'title_zh': cfg['title_zh'],
+ 'title_status': cfg['title_status'], 'evidence': cfg.get('evidence', []),
+ 'edition_revision': cfg['edition_revision'], 'original': original,
+ 'input_sha256': sha(epub.read_bytes()), 'cover_sha256': sha(image),
+ 'authors': cfg.get('authors_zh', original['authors']),
+ 'languages': ['zh-CN'] if mode == 'target_only' else list(dict.fromkeys(['zh-CN'] + original['languages'])),
+ 'cover_bindings': [],
+ 'image_generation': reviewed, 'config_sha256': sha(Path(config).read_bytes())}
+ identity = json.dumps(record, sort_keys=True, ensure_ascii=False)
+ identifier = 'urn:uuid:' + str(uuid.uuid5(uuid.NAMESPACE_URL, identity))
+ record['identifier'] = identifier
+ set_values(metadata, 'title', [title])
+ if cfg.get('authors_zh'):
+ if len(cfg['authors_zh']) != len(original['authors']):
+ raise ValueError('authors_zh must match the original author count')
+ set_values(metadata, 'creator', cfg['authors_zh'])
+ for creator in metadata.findall(f'{{{DC}}}creator'):
+ creator.set(f'{{{OPF}}}role', 'aut') if root.get('version', '').startswith('2') else None
+ set_values(metadata, 'language', record['languages'])
+ # Source ISBN and Calibre UUID must not identify this AI edition as the publisher's book.
+ set_values(metadata, 'identifier', [identifier])
+ identity_node = metadata.find(f'{{{DC}}}identifier')
+ used_ids = set(root.xpath('//@id')); new_id = 'translation-edition-id'
+ while new_id in used_ids: new_id += '-new'
+ identity_node.set('id', new_id); root.set('unique-identifier', new_id)
+ for value in original['identifiers']:
+ etree.SubElement(metadata, f'{{{DC}}}source').text = value
+ etree.SubElement(metadata, f'{{{DC}}}relation').text = 'Original title: ' + ' — '.join(original['titles'])
+ description = f'AI译本;{LABELS[mode]};版本 {cfg["edition_revision"]}。中文书名来源:' + ('正式出版译名(仅借用书名,本文件不是该出版译本)。' if cfg['title_status'] == 'authoritative' else '项目译名,尚未确认正式出版译名。')
+ etree.SubElement(metadata, f'{{{DC}}}description').text = description
+ # Publisher/date belong to the source, not the newly generated edition.
+ set_values(metadata, 'publisher', ['EPUB Bilingual Translator(AI译本)'])
+ set_values(metadata, 'date', [datetime.now(timezone.utc).strftime('%Y-%m-%d')])
+ for meta in list(metadata.findall(f'{{{OPF}}}meta')):
+ if meta.get('name') in {'calibre:title_sort', 'calibre:author_sort', 'calibre:timestamp'}:
+ metadata.remove(meta)
+ etree.SubElement(metadata, f'{{{OPF}}}meta', name='calibre:title_sort', content=title)
+ ids = {x.get('id') for x in manifest}; cover_id = 'translation-cover'
+ while cover_id in ids: cover_id += '-new'
+ additions = {}; replacements = {}
+ with zipfile.ZipFile(epub) as z:
+ href = 'translation-cover-' + sha(image)[:16] + suffix
+ image_path = archive_path(opf_path, href)
+ while image_path in z.namelist():
+ href = 'new-' + href; image_path = archive_path(opf_path, href)
+ record['cover_path'] = image_path; record['cover_id'] = cover_id
+ wrapper_targets = {}
+ for item in list(manifest):
+ props = item.get('properties', '').split()
+ if 'cover-image' in props:
+ props.remove('cover-image')
+ if props: item.set('properties', ' '.join(props))
+ else: item.attrib.pop('properties', None)
+ if old_path and item.get('media-type') in {'application/xhtml+xml', 'image/svg+xml'}:
+ path = archive_path(opf_path, item.get('href'))
+ doc = etree.fromstring(z.read(path), PARSER()); changed = False
+ for node in doc.iter():
+ if not isinstance(node.tag, str): continue
+ for attr in ('src', 'data', 'href', '{http://www.w3.org/1999/xlink}href'):
+ value = node.get(attr)
+ if value and etree.QName(node).localname in {'img', 'image', 'object'} and archive_path(path, value) == old_path:
+ node.set(attr, quote(posixpath.relpath(image_path, posixpath.dirname(path))))
+ if node.get('alt') is not None: node.set('alt', title)
+ if etree.QName(node).localname == 'img':
+ node.set('style', node.get('style', '').rstrip(';') + ';max-width:100%;height:auto;max-height:98vh;object-fit:contain')
+ if etree.QName(node).localname == 'image':
+ node.set('preserveAspectRatio', 'xMidYMid meet')
+ for ancestor in node.iterancestors():
+ if isinstance(ancestor.tag, str) and etree.QName(ancestor).localname == 'svg':
+ ancestor.set('preserveAspectRatio', 'xMidYMid meet')
+ changed = True
+ if changed:
+ data = etree.tostring(doc, encoding='utf-8', xml_declaration=True)
+ if item.get('media-type') == 'image/svg+xml':
+ # Clone SVG wrappers to keep every original image byte intact.
+ clone_href = posixpath.join(posixpath.dirname(item.get('href')), 'translation-wrapper-' + sha(data)[:16] + '.svg')
+ clone_path = archive_path(opf_path, clone_href)
+ while clone_path in z.namelist() or clone_path in additions:
+ clone_href = posixpath.join(posixpath.dirname(clone_href), 'new-' + posixpath.basename(clone_href))
+ clone_path = archive_path(opf_path, clone_href)
+ clone_id = cover_id + '-wrapper'
+ while clone_id in ids: clone_id += '-new'
+ ids.add(clone_id)
+ etree.SubElement(manifest, f'{{{OPF}}}item', id=clone_id, href=clone_href, **{'media-type':'image/svg+xml'})
+ additions[clone_path] = data; wrapper_targets[path] = clone_path
+ record['cover_bindings'].append({'file':clone_path, 'target':image_path})
+ else:
+ replacements[path] = data
+ record['cover_bindings'].append({'file':path, 'target':image_path})
+ if item.get('media-type') == 'application/x-dtbncx+xml':
+ path = archive_path(opf_path, item.get('href')); ncx = etree.fromstring(z.read(path), PARSER())
+ for node in ncx.xpath('//*[local-name()="meta" and @name="dtb:uid"]'): node.set('content', identifier)
+ for node in ncx.xpath('//*[local-name()="docTitle"]/*[local-name()="text"]'): node.text = title
+ replacements[path] = etree.tostring(ncx, encoding='utf-8', xml_declaration=True)
+ for item in manifest:
+ if item.get('media-type') != 'application/xhtml+xml' or not wrapper_targets: continue
+ path = archive_path(opf_path, item.get('href')); doc = etree.fromstring(replacements.get(path, z.read(path)), PARSER()); changed = False
+ for node in doc.iter():
+ if not isinstance(node.tag, str) or etree.QName(node).localname not in {'img','image','object'}: continue
+ for attr in ('src','data','href','{http://www.w3.org/1999/xlink}href'):
+ value = node.get(attr)
+ target = wrapper_targets.get(archive_path(path, value)) if value else None
+ if target:
+ node.set(attr, quote(posixpath.relpath(target, posixpath.dirname(path)))); changed = True
+ record['cover_bindings'].append({'file':path,'target':target})
+ if changed: replacements[path] = etree.tostring(doc, encoding='utf-8', xml_declaration=True)
+ attrs = {'id': cover_id, 'href': href, 'media-type': mime}
+ if root.get('version', '').startswith('3'): attrs['properties'] = 'cover-image'
+ etree.SubElement(manifest, f'{{{OPF}}}item', **attrs); additions[image_path] = image
+ cover_meta = [x for x in metadata.findall(f'{{{OPF}}}meta') if x.get('name') == 'cover']
+ for node in cover_meta: metadata.remove(node)
+ etree.SubElement(metadata, f'{{{OPF}}}meta', name='cover', content=cover_id)
+ if old_cover is None:
+ # A source with no raster cover gets a new front page without changing its TOC.
+ page_id = cover_id + '-page'; page_href = href + '.xhtml'
+ while page_id in ids: page_id += '-new'
+ page_path = archive_path(opf_path, page_href)
+ while page_path in z.namelist():
+ page_href = 'new-' + page_href; page_path = archive_path(opf_path, page_href)
+ page = etree.Element('{http://www.w3.org/1999/xhtml}html', nsmap={None: 'http://www.w3.org/1999/xhtml'})
+ head = etree.SubElement(page, '{http://www.w3.org/1999/xhtml}head'); etree.SubElement(head, '{http://www.w3.org/1999/xhtml}title').text = title
+ body = etree.SubElement(page, '{http://www.w3.org/1999/xhtml}body'); etree.SubElement(body, '{http://www.w3.org/1999/xhtml}img', src=href, alt=title, style='width:100%;height:auto;max-height:98vh;object-fit:contain')
+ additions[page_path] = etree.tostring(page, encoding='utf-8', xml_declaration=True)
+ record['cover_bindings'].append({'file':page_path, 'target':image_path})
+ etree.SubElement(manifest, f'{{{OPF}}}item', id=page_id, href=page_href, **{'media-type': 'application/xhtml+xml'})
+ root.find(f'{{{OPF}}}spine').insert(0, etree.Element(f'{{{OPF}}}itemref', idref=page_id, linear='yes'))
+ if root.get('version', '').startswith('3'):
+ prefix = root.get('prefix', '')
+ if 'translation:' in prefix: raise ValueError('Source already defines translation prefix')
+ root.set('prefix', (prefix + ' translation: ' + PREFIX).strip())
+ etree.SubElement(metadata, f'{{{OPF}}}meta', property=RECORD_KEY).text = json.dumps(record, ensure_ascii=False)
+ modified = [node for node in metadata.findall(f'{{{OPF}}}meta') if node.get('property') == 'dcterms:modified']
+ if not modified: modified = [etree.SubElement(metadata, f'{{{OPF}}}meta', property='dcterms:modified')]
+ for node in modified: node.text = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
+ else:
+ etree.SubElement(metadata, f'{{{OPF}}}meta', name=RECORD_KEY, content=json.dumps(record, ensure_ascii=False))
+ replacements[opf_path] = etree.tostring(root, encoding='utf-8', xml_declaration=True)
+ output.parent.mkdir(parents=True, exist_ok=True); temp = output.with_suffix(output.suffix + '.tmp')
+ try:
+ with zipfile.ZipFile(temp, 'w', compression=zipfile.ZIP_DEFLATED) as target:
+ target.writestr('mimetype', b'application/epub+zip', compress_type=zipfile.ZIP_STORED)
+ for info in z.infolist():
+ if info.filename != 'mimetype': target.writestr(info, replacements.get(info.filename, z.read(info.filename)))
+ for name, data in additions.items(): target.writestr(name, data)
+ temp.replace(output)
+ finally: temp.unlink(missing_ok=True)
+ return record
+
+
+def audit_edition(epub):
+ """Verify library-facing metadata and cover hash, including EPUB2 cover binding."""
+ opf_path, root = read_package(epub); record = edition_record(root)
+ if not record: return None
+ issues = []
+ if dc_values(root, 'title') != [record['title']]: issues.append('edition_title_mismatch')
+ if dc_values(root, 'creator') != record['authors']: issues.append('edition_author_mismatch')
+ if dc_values(root, 'language') != record['languages']: issues.append('edition_language_mismatch')
+ identifier = root.xpath('//*[local-name()="identifier" and @id=$id]/text()', id=root.get('unique-identifier'))
+ if identifier != [record['identifier']]: issues.append('edition_identifier_mismatch')
+ item = cover_item(root)
+ if item is None or item.get('id') != record['cover_id'] or archive_path(opf_path, item.get('href')) != record['cover_path']:
+ issues.append('edition_cover_binding_mismatch')
+ with zipfile.ZipFile(epub) as z:
+ if record['cover_path'] not in z.namelist() or sha(z.read(record['cover_path'])) != record['cover_sha256']:
+ issues.append('edition_cover_hash_mismatch')
+ for binding in record['cover_bindings']:
+ path = binding['file']
+ if path not in z.namelist():
+ issues.append('edition_cover_document_missing'); continue
+ doc = etree.fromstring(z.read(path), PARSER())
+ refs = [archive_path(path, value) for node in doc.iter() if isinstance(node.tag, str) and etree.QName(node).localname in {'img', 'image', 'object'}
+ for attr in ('src', 'data', 'href', '{http://www.w3.org/1999/xlink}href') if (value := node.get(attr))]
+ if binding['target'] not in refs: issues.append('edition_cover_document_binding_mismatch')
+ return {'title': record['title'], 'identifier': record['identifier'], 'mode': record['mode'],
+ 'title_status': record['title_status'], 'evidence': record['evidence'], 'issues': issues}
+
+
+def validate_config_assets(epub, config, mode):
+ config = Path(config); cfg = load_config(config)
+ _, root = read_package(epub); verify_binding(root, cfg)
+ asset = cfg.get('covers', {}).get(mode)
+ if not asset or not asset.get('image') or not asset.get('review'):
+ raise ValueError(f'No reviewed cover configured for {mode}; run localize_edition prepare first')
+ image, review = config.parent/asset['image'], config.parent/asset['review']
+ reviewed_cover(image, review, cfg, mode)
+ return image, review
+
+
+def localize_from_config(epub, config, mode):
+ """Optional pipeline hook; asset paths are relative to the edition JSON."""
+ image, review = validate_config_assets(epub, config, mode)
+ output = Path(epub).with_name('edition_' + Path(epub).name)
+ localize(epub, output, config, mode, image, review)
+ return output
diff --git a/src/qa/headless.py b/src/qa/headless.py
index aaa6ca2..341b58f 100644
--- a/src/qa/headless.py
+++ b/src/qa/headless.py
@@ -196,10 +196,14 @@ def coverage_report(manifest, source, structure=None):
def audit_pair(source, translated, manifest=None, structure=None):
+ from src.edition.localize import audit_edition
+ edition = audit_edition(translated)
before = book_inventory(source)
after = book_inventory(translated)
sa, ta = audit_epub(source), audit_epub(translated)
issues = compare_audits(sa, ta) + compare_navigation(before, after)
+ if edition:
+ issues.extend({'kind': error, 'detail': 'Inspect edition cover/metadata'} for error in edition['issues'])
baseline_nav = navigation_markup(before)
issues.extend(f for f in navigation_markup(after) if f not in baseline_nav)
for name, digest in before['binaries'].items():
@@ -252,7 +256,7 @@ def audit_pair(source, translated, manifest=None, structure=None):
'source_sha256': hashlib.sha256(Path(source).read_bytes()).hexdigest(),
'translated_sha256': hashlib.sha256(Path(translated).read_bytes()).hexdigest(),
'source_audit': sa, 'translated_audit': ta, 'new_issues': issues,
- 'coverage': coverage, 'toc_items': len(after['toc']),
+ 'coverage': coverage, 'edition': edition, 'toc_items': len(after['toc']),
'unchanged_binary_resources': len(before['binaries']),
'notes': {'source': sn, 'translated': tn}, 'visual_review': 'pending',
'render_status': 'not_requested', 'semantic_review': 'pending'}, before, after
@@ -304,6 +308,11 @@ LAYOUT_JS = """() => {
def render_pair(source, translated, before, after, out, chapters=None, max_documents=4, pages=2, width=900, height=1200):
from playwright.sync_api import sync_playwright
+ from src.edition.localize import read_package, edition_record, audit_edition
+ edition = edition_record(read_package(translated)[1])
+ added_cover_pages = set()
+ if edition and not audit_edition(translated)['issues']:
+ added_cover_pages = {b['file'] for b in edition['cover_bindings'] if b['target'] == edition['cover_path']}
candidates = [n for n in after['spine'] if n in after['documents']]
risky = []
for predicate in (lambda n: re.search(r'part|chapter|ch\d', n, re.I),
@@ -316,7 +325,7 @@ def render_pair(source, translated, before, after, out, chapters=None, max_docum
if not selected:
raise ValueError('No documents selected for rendering')
for name in selected:
- if name not in before['documents'] or name not in after['documents']:
+ if name not in after['documents'] or (name not in before['documents'] and name not in added_cover_pages):
raise ValueError(f'Document absent from source or translation: {name}')
evidence = []; layout = []; clicks = []; browser_version = None
with archive_server({'source': source, 'translated': translated}) as base, sync_playwright() as p:
@@ -328,6 +337,7 @@ def render_pair(source, translated, before, after, out, chapters=None, max_docum
page = context.new_page()
for index, name in enumerate(selected):
for label in ('source', 'translated'):
+ if label == 'source' and name not in before['documents']: continue
url = base + '/' + label + '/' + quote(name)
page.goto(url, wait_until='networkidle', timeout=30000)
page.evaluate('() => document.fonts.ready')
diff --git a/tests/test_edition.py b/tests/test_edition.py
new file mode 100644
index 0000000..1979c75
--- /dev/null
+++ b/tests/test_edition.py
@@ -0,0 +1,144 @@
+"""Reader-facing edition identity and cover binding, EPUB2/3 and missing-cover cases."""
+import base64
+import json
+import zipfile
+from pathlib import Path
+import pytest
+from lxml import etree
+from src.edition.localize import localize, prepare, sha, read_package, dc_values, audit_edition, edition_record, localize_from_config
+from src.qa.headless import audit_pair
+
+PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=')
+OLD = PNG + b'original'
+
+
+def fixture(tmp_path, version='3.0', has_cover=True, svg=False):
+ path = tmp_path/'input.epub'
+ cover_meta = '' if has_cover else ''
+ cover_item = '
'
+ opf = f'''
Title page
') + '') + z.writestr('EPUB/chapter.xhtml', 'Keep my text and illustration.
')
+ z.writestr('EPUB/toc.ncx', '