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 = '' if has_cover else '' + cover_doc = '' if svg else 'old title' + opf = f'''Original WorkAn Author9780000000001enOriginal Publisher{cover_meta}mainAuthor, An{cover_item}''' + with zipfile.ZipFile(path, 'w') as z: + z.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED) + z.writestr('META-INF/container.xml', '') + z.writestr('EPUB/package.opf', opf) + z.writestr('EPUB/cover.xhtml', 'Cover'+ (cover_doc if has_cover else '

Title page

') + '') + z.writestr('EPUB/chapter.xhtml', 'Chapter

Chapter

Keep my text and illustration.

') + z.writestr('EPUB/toc.ncx', 'Original WorkChapter') + 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'' + 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'',b'') + 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()