Add Chinese covers and source-bound library edition metadata
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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": "軟銀創辦人顛覆全球科技與金融版圖的傳奇人生"
|
||||||
|
}
|
||||||
@@ -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": "中国最强大公司的秘史"
|
||||||
|
}
|
||||||
@@ -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 正确并不等于生图文字或全书语义审校通过。本轮仅扩大封面视觉验证,未重新做全书逐页语义审校,也未声称所有阅读器分页完全一致。
|
||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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 的已审书名不是全书语义验收。
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Localize library-facing covers and metadata without retranslating the book."""
|
||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user