Add Chinese covers and source-bound library edition metadata

This commit is contained in:
Kai Tan
2026-10-08 12:58:32 +08:00
parent 8297e6d2c8
commit 5dbd7bd240
14 changed files with 699 additions and 3 deletions
+9
View File
@@ -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 ## 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. - Add discoverable `epub-translate` skill for full-book agent/API translation, terminology review, selective repair and evidence-based acceptance.
+39
View File
@@ -110,3 +110,42 @@ Mac 沙箱中 Calibre 的 Qt 渲染可能需要允许进程运行。QA 使用项
无法可靠绑定的目录条目仅翻译标签,不改写猜测的章节正文;具体条目记录在 `book_structure.json` 的 `navigation_titles[].binding_note`。这类条目需要检查章节标题是否一致,不能视为全书标题同步已经通过。索引、版权、注释全文仍遵守原有跳过策略;导航中这些页面的名称可以翻译。 无法可靠绑定的目录条目仅翻译标签,不改写猜测的章节正文;具体条目记录在 `book_structure.json` 的 `navigation_titles[].binding_note`。这类条目需要检查章节标题是否一致,不能视为全书标题同步已经通过。索引、版权、注释全文仍遵守原有跳过策略;导航中这些页面的名称可以翻译。
后续扫描 PDF/OCR 大版本的图文、结构与目录方案见 [路线图](doc/OCR_EPUB_ROADMAP.md)。 后续扫描 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。
+29
View File
@@ -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": "軟銀創辦人顛覆全球科技與金融版圖的傳奇人生"
}
+17
View File
@@ -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": "中国最强大公司的秘史"
}
+33
View File
@@ -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 正确并不等于生图文字或全书语义审校通过。本轮仅扩大封面视觉验证,未重新做全书逐页语义审校,也未声称所有阅读器分页完全一致。
+7
View File
@@ -40,6 +40,9 @@ from src.common.paths import get_work_dirs
async def run_pipeline(args): async def run_pipeline(args):
input_path = Path(args.input_epub) input_path = Path(args.input_epub)
output_dir = Path(args.output_dir) 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 # Get work directories for this book
work = get_work_dirs(input_path) work = get_work_dirs(input_path)
@@ -164,6 +167,9 @@ async def run_pipeline(args):
output_path = output_dir / output_filename output_path = output_dir / output_filename
created_epub = builder.build(updated_structure, output_path) 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 from src.qa.epub_audit import audit_epub, compare_audits
source_audit, output_audit = audit_epub(input_path), audit_epub(created_epub) 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("input_epub", help="Path to the input EPUB file")
parser.add_argument("--output-dir", default="output", help="Directory for output files") 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("--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("--glossary", help="Per-book JSON terminology file")
parser.add_argument("--config", default="config/config.yaml", help="Local YAML configuration 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)") parser.add_argument("--env-file", help="Local credential dotenv file (never committed)")
+9 -1
View File
@@ -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))]}) '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}') print(f'Prepared {len(entries)} complete-book units; {len(batches)} pending batches; {work}')
return 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) structure = verify_source(args.source, work)
metadata = json.loads((work/'agent-run.json').read_text()) metadata = json.loads((work/'agent-run.json').read_text())
if metadata['model'] != args.model: if metadata['model'] != args.model:
@@ -114,7 +118,10 @@ async def run(args):
for mode, prefix in [('target_only', 'translated_'), ('bilingual', 'bilingual_')]: for mode, prefix in [('target_only', 'translated_'), ('bilingual', 'bilingual_')]:
copy = structure.model_copy(deep=True) copy = structure.model_copy(deep=True)
await BackfillEngine().backfill(copy, entries, mode=mode) 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, save(work/'translation-provenance.json', {'model': args.model, 'source_sha256': structure.source_sha256,
'total': len(entries), 'accepted': len(entries), 'provider': 'agent-authored manifest'}) 'total': len(entries), 'accepted': len(entries), 'provider': 'agent-authored manifest'})
print(f'Built both full-book modes: {len(entries)}/{len(entries)}; {output}') print(f'Built both full-book modes: {len(entries)}/{len(entries)}; {output}')
@@ -124,6 +131,7 @@ def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['prepare', 'apply', 'build']); parser.add_argument('source', type=Path) 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('--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('--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) parser.add_argument('--model', default='agent/luna'); parser.add_argument('--batch-chars', type=int, default=16000)
args = parser.parse_args() args = parser.parse_args()
+35
View File
@@ -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())
+6
View File
@@ -38,3 +38,9 @@ description: Translate complete EPUB books and iteratively review terminology, n
有证据的失败修复后重建两版并重跑受影响检查;连续出现相同失败且没有新证据时保留待审记录,避免重复付费请求;有明确修复线索时继续定位和验证。不把 Chromium 连续 XHTML 排版描述为所有阅读器的分页效果。 有证据的失败修复后重建两版并重跑受影响检查;连续出现相同失败且没有新证据时保留待审记录,避免重复付费请求;有明确修复线索时继续定位和验证。不把 Chromium 连续 XHTML 排版描述为所有阅读器的分页效果。
交付实际 EPUB 链接、模型/来源记录、工程检查结果、已审截图和语义抽查范围,以及未解决项。只有全部提取单元有效并完整回填时才称“完整译本”;没有全书逐页审校时使用“抽样 QA”。书籍、密钥和 `.work` 数据留在本地,代码及 skill 的推送沿用用户已有授权。 交付实际 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。
@@ -75,3 +75,15 @@ Linux 系统运行库仍可能需要官方 `install --with-deps chromium --only-
退出 1 表示工程检查/完整度/注号点击失败;退出 2 表示所请求的渲染未成功,不以缺依赖降级成通过。`report.json` 保存源/输出 SHA、基线问题、覆盖范围、注释分类、布局提示和真实浏览器点击结果;`review.html` 和 PNG 是视觉证据。 退出 1 表示工程检查/完整度/注号点击失败;退出 2 表示所请求的渲染未成功,不以缺依赖降级成通过。`report.json` 保存源/输出 SHA、基线问题、覆盖范围、注释分类、布局提示和真实浏览器点击结果;`review.html` 和 PNG 是视觉证据。
查看截图后另写 `review-record.json`:已审文档/图片、发现项、修改与复验结果、语义抽查单元、剩余问题。原始自动报告的 visual/semantic 状态保持 pending,防止后续把机器生成证据误当人工验收。源码里不存在的网站尾注不在“完整源 EPUB 翻译”范围内;若用户要求纳入,单独获取、翻译并重建注号目标,不能猜注释内容。 查看截图后另写 `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 的已审书名不是全书语义验收。
+1
View File
@@ -0,0 +1 @@
"""Localize library-facing covers and metadata without retranslating the book."""
+346
View File
@@ -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
+12 -2
View File
@@ -196,10 +196,14 @@ def coverage_report(manifest, source, structure=None):
def audit_pair(source, translated, manifest=None, 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) before = book_inventory(source)
after = book_inventory(translated) after = book_inventory(translated)
sa, ta = audit_epub(source), audit_epub(translated) sa, ta = audit_epub(source), audit_epub(translated)
issues = compare_audits(sa, ta) + compare_navigation(before, after) 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) baseline_nav = navigation_markup(before)
issues.extend(f for f in navigation_markup(after) if f not in baseline_nav) issues.extend(f for f in navigation_markup(after) if f not in baseline_nav)
for name, digest in before['binaries'].items(): 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(), 'source_sha256': hashlib.sha256(Path(source).read_bytes()).hexdigest(),
'translated_sha256': hashlib.sha256(Path(translated).read_bytes()).hexdigest(), 'translated_sha256': hashlib.sha256(Path(translated).read_bytes()).hexdigest(),
'source_audit': sa, 'translated_audit': ta, 'new_issues': issues, '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']), 'unchanged_binary_resources': len(before['binaries']),
'notes': {'source': sn, 'translated': tn}, 'visual_review': 'pending', 'notes': {'source': sn, 'translated': tn}, 'visual_review': 'pending',
'render_status': 'not_requested', 'semantic_review': 'pending'}, before, after '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): 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 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']] candidates = [n for n in after['spine'] if n in after['documents']]
risky = [] risky = []
for predicate in (lambda n: re.search(r'part|chapter|ch\d', n, re.I), 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: if not selected:
raise ValueError('No documents selected for rendering') raise ValueError('No documents selected for rendering')
for name in selected: 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}') raise ValueError(f'Document absent from source or translation: {name}')
evidence = []; layout = []; clicks = []; browser_version = None evidence = []; layout = []; clicks = []; browser_version = None
with archive_server({'source': source, 'translated': translated}) as base, sync_playwright() as p: 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() page = context.new_page()
for index, name in enumerate(selected): for index, name in enumerate(selected):
for label in ('source', 'translated'): for label in ('source', 'translated'):
if label == 'source' and name not in before['documents']: continue
url = base + '/' + label + '/' + quote(name) url = base + '/' + label + '/' + quote(name)
page.goto(url, wait_until='networkidle', timeout=30000) page.goto(url, wait_until='networkidle', timeout=30000)
page.evaluate('() => document.fonts.ready') page.evaluate('() => document.fonts.ready')
+144
View File
@@ -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 = '<meta name="cover" content="old-cover"/>' if has_cover else ''
cover_item = '<item id="old-cover" href="images/old.png" media-type="image/png"' + (' properties="cover-image"' if version == '3.0' else '') + '/>' if has_cover else ''
cover_doc = '<!-- a publisher comment --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="images/old.png"/></svg>' if svg else '<img src="images/old.png" alt="old title"/>'
opf = f'''<package xmlns="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/" version="{version}" unique-identifier="old-id"><metadata><dc:title id="title">Original Work</dc:title><dc:creator id="author">An Author</dc:creator><dc:identifier id="old-id">9780000000001</dc:identifier><dc:language>en</dc:language><dc:publisher>Original Publisher</dc:publisher>{cover_meta}<meta name="calibre:title_sort" content="Original Work"/><meta property="title-type" refines="#title">main</meta><meta property="file-as" refines="#author">Author, An</meta></metadata><manifest>{cover_item}<item id="cover-page" href="cover.xhtml" media-type="application/xhtml+xml"/><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="toc" href="toc.ncx" media-type="application/x-dtbncx+xml"/><item id="art" href="images/body.png" media-type="image/png"/></manifest><spine toc="toc"><itemref idref="cover-page"/><itemref idref="chapter"/></spine></package>'''
with zipfile.ZipFile(path, 'w') as z:
z.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
z.writestr('META-INF/container.xml', '<container><rootfiles><rootfile full-path="EPUB/package.opf"/></rootfiles></container>')
z.writestr('EPUB/package.opf', opf)
z.writestr('EPUB/cover.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Cover</title></head><body>'+ (cover_doc if has_cover else '<p>Title page</p>') + '</body></html>')
z.writestr('EPUB/chapter.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body><h1 id="chapter">Chapter</h1><p>Keep my text and illustration.</p><img src="images/body.png"/></body></html>')
z.writestr('EPUB/toc.ncx', '<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><head><meta name="dtb:uid" content="9780000000001"/></head><docTitle><text>Original Work</text></docTitle><navMap><navPoint id="ch"><navLabel><text>Chapter</text></navLabel><content src="chapter.xhtml#chapter"/></navPoint></navMap></ncx>')
z.writestr('EPUB/images/body.png', OLD)
if has_cover: z.writestr('EPUB/images/old.png', OLD)
cfg = tmp_path/'edition.json'
config = {'source': {'titles':['Original Work'], 'authors':['An Author'], 'identifier':'9780000000001'},
'title_zh':'权威书名', 'authors_zh':['作者译名'], 'title_status':'authoritative',
'evidence':[{'url':'https://publisher.example/book'}], 'edition_revision':'luna-r2'}
cfg.write_text(json.dumps(config)); image = tmp_path/'generated.any'; image.write_bytes(PNG)
review = tmp_path/'review.json'
def review_for(mode):
review.write_text(json.dumps({'cover_sha256':sha(PNG), 'mode':mode, 'title_zh':config['title_zh'],
'edition_revision':'luna-r2', 'visual_review':'passed', 'provider':'test fixture', 'model':None}))
review_for('target_only')
return path, cfg, image, review, review_for
@pytest.mark.parametrize('version', ['2.0', '3.0'])
@pytest.mark.parametrize('svg', [False, True])
def test_reader_cover_identity_and_body_preservation(tmp_path, version, svg):
source, cfg, image, review, update = fixture(tmp_path, version, svg=svg)
ids = []
for mode in ['target_only','bilingual']:
update(mode); out = tmp_path/(mode+'.epub')
record = localize(source, out, cfg, mode, image, review); ids.append(record['identifier'])
_, root = read_package(out)
assert dc_values(root,'title') == [record['title']]
assert dc_values(root,'creator') == ['作者译名']
assert dc_values(root,'identifier') == [record['identifier']]
assert '9780000000001' in dc_values(root,'source')
assert dc_values(root,'language') == (['zh-CN'] if mode == 'target_only' else ['zh-CN','en'])
assert not root.xpath('//*[@refines="#title" or @refines="#author"]')
assert audit_edition(out)['issues'] == []
report, _, _ = audit_pair(source, out); assert report['new_issues'] == []
with zipfile.ZipFile(out) as z, zipfile.ZipFile(source) as original:
assert z.read('EPUB/chapter.xhtml') == original.read('EPUB/chapter.xhtml')
assert z.read('EPUB/images/old.png') == OLD
assert z.read('EPUB/images/body.png') == OLD
assert z.read(record['cover_path']) == PNG
assert record['cover_path'].endswith('.png')
doc=etree.fromstring(z.read('EPUB/cover.xhtml'))
ref = doc.xpath('//*[local-name()="image"]/@*[local-name()="href"]') if svg else doc.xpath('//*[local-name()="img"]/@src')
assert ref == [Path(record['cover_path']).name]
if svg: assert doc.xpath('//*[local-name()="svg"]/@preserveAspectRatio') == ['xMidYMid meet']
assert z.infolist()[0].filename == 'mimetype' and z.infolist()[0].compress_type == zipfile.ZIP_STORED
assert ids[0] != ids[1]
def test_absent_cover_adds_front_page_and_preserves_navigation(tmp_path):
source,cfg,image,review,_ = fixture(tmp_path,has_cover=False)
job = prepare(source,cfg,'target_only',tmp_path/'job'); assert job['reference'] is None
out=tmp_path/'new.epub'; localize(source,out,cfg,'target_only',image,review)
_,root=read_package(out)
assert root.xpath('//*[local-name()="spine"]/*[1]/@idref') == ['translation-cover-page']
assert audit_pair(source,out)[0]['new_issues'] == []
def test_fail_closed_for_wrong_book_unreviewed_cover_and_no_authority(tmp_path):
source,cfg,image,review,_ = fixture(tmp_path)
review.write_text('{}')
with pytest.raises(ValueError,match='Cover review'): localize(source,tmp_path/'out.epub',cfg,'target_only',image,review)
data=json.loads(cfg.read_text()); data['evidence']=[]; cfg.write_text(json.dumps(data))
with pytest.raises(ValueError,match='evidence'): prepare(source,cfg,'target_only',tmp_path/'job')
data['title_status']='project'; data['source']['titles']=['Wrong book']; cfg.write_text(json.dumps(data))
with pytest.raises(ValueError,match='does not match'): prepare(source,cfg,'target_only',tmp_path/'job')
def test_corrupt_cover_is_detected_by_headless_audit(tmp_path):
source,cfg,image,review,_=fixture(tmp_path); out=tmp_path/'out.epub'
record=localize(source,out,cfg,'target_only',image,review)
corrupted=tmp_path/'corrupt.epub'
with zipfile.ZipFile(out) as z, zipfile.ZipFile(corrupted,'w') as target:
for info in z.infolist(): target.writestr(info,b'broken' if info.filename==record['cover_path'] else z.read(info.filename))
assert 'edition_cover_hash_mismatch' in audit_edition(corrupted)['issues']
assert any(x['kind']=='edition_cover_hash_mismatch' for x in audit_pair(source,corrupted)[0]['new_issues'])
def test_pipeline_asset_paths_are_relative_to_config(tmp_path):
source,cfg,image,review,_=fixture(tmp_path)
data=json.loads(cfg.read_text()); data['covers']={'target_only':{'image':image.name,'review':review.name}}; cfg.write_text(json.dumps(data))
output=localize_from_config(source,cfg,'target_only'); assert output.exists()
with pytest.raises(ValueError,match='Already localized'): localize(output,tmp_path/'again.epub',cfg,'target_only',image,review)
with pytest.raises(ValueError,match='overwrite'): localize(source,source,cfg,'target_only',image,review)
def test_external_svg_cover_wrapper_is_cloned_not_overwritten(tmp_path):
source,cfg,image,review,_=fixture(tmp_path)
original=tmp_path/'wrapped.epub'
svg=b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="old.png"/></svg>'
with zipfile.ZipFile(source) as z,zipfile.ZipFile(original,'w') as target:
for info in z.infolist():
data=z.read(info.filename)
if info.filename=='EPUB/package.opf':
data=data.replace(b'</manifest>',b'<item id="wrapper" href="images/wrapper.svg" media-type="image/svg+xml"/></manifest>')
if info.filename=='EPUB/cover.xhtml': data=data.replace(b'images/old.png',b'images/wrapper.svg')
target.writestr(info,data)
target.writestr('EPUB/images/wrapper.svg',svg)
out=tmp_path/'localized.epub'; record=localize(original,out,cfg,'target_only',image,review)
with zipfile.ZipFile(out) as z:
assert z.read('EPUB/images/wrapper.svg')==svg
assert b'translation-wrapper-' in z.read('EPUB/cover.xhtml')
assert len(record['cover_bindings'])==2
assert audit_pair(original,out)[0]['new_issues']==[]
def test_headless_can_render_a_new_cover_without_a_source_counterpart(tmp_path):
import os
if not os.environ.get('PLAYWRIGHT_BROWSERS_PATH'):
pytest.skip('Optional browser integration; set PLAYWRIGHT_BROWSERS_PATH')
pytest.importorskip('playwright.sync_api')
from src.qa.headless import book_inventory, render_pair
source,cfg,image,review,_ = fixture(tmp_path,has_cover=False)
out=tmp_path/'edition.epub'; record=localize(source,out,cfg,'target_only',image,review)
cover_page=record['cover_bindings'][0]['file']
evidence=tmp_path/'evidence';evidence.mkdir()
result=render_pair(source,out,book_inventory(source),book_inventory(out),evidence,[cover_page],1,1)
assert result['sampled_documents']==[cover_page]
assert len(result['images'])==1 and result['images'][0]['book']=='translated'
assert (evidence/result['images'][0]['image']).exists()