Add Chinese covers and source-bound library edition metadata

This commit is contained in:
Kai Tan
2026-10-08 12:58:32 +08:00
parent 8297e6d2c8
commit 5dbd7bd240
14 changed files with 699 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
"""Localize library-facing covers and metadata without retranslating the book."""
+346
View File
@@ -0,0 +1,346 @@
"""Offline, source-bound edition packaging. Image generation belongs to the skill."""
import hashlib
import json
import posixpath
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import unquote, urlsplit, quote
from lxml import etree
from src.assembly.package_writer import package_path, OPF, DC
LABELS = {'target_only': '中文版', 'bilingual': '中英对照版'}
PREFIX = 'https://kais.cloud/epub-translator/metadata/'
RECORD_KEY = 'translation:edition'
PARSER = lambda: etree.XMLParser(resolve_entities=False, no_network=True)
def sha(data):
return hashlib.sha256(data).hexdigest()
def archive_path(document, href):
url = urlsplit(href)
if url.scheme or url.netloc:
return None
return posixpath.normpath(posixpath.join(posixpath.dirname(document), unquote(url.path)))
def read_package(path):
with zipfile.ZipFile(path) as z:
name = package_path(z)
return name, etree.fromstring(z.read(name), PARSER())
def dc_values(root, key):
return [x.text or '' for x in root.findall(f'{{{OPF}}}metadata/{{{DC}}}{key}')]
def load_config(path):
cfg = json.loads(Path(path).read_text())
if not cfg.get('title_zh') or cfg.get('title_status') not in {'authoritative', 'project'}:
raise ValueError('Need title_zh and title_status (authoritative/project)')
if cfg['title_status'] == 'authoritative' and not cfg.get('evidence'):
raise ValueError('Authoritative title requires publisher/library evidence URLs')
if not cfg.get('source', {}).get('titles') or not cfg['source'].get('authors'):
raise ValueError('Bind the edition to source titles and authors')
if not cfg.get('edition_revision'):
raise ValueError('Need edition_revision to distinguish translation revisions')
return cfg
def verify_binding(root, cfg):
# Exact source matching prevents applying a researched title to another work.
if dc_values(root, 'title') != cfg['source']['titles'] or dc_values(root, 'creator') != cfg['source']['authors']:
raise ValueError('Edition config does not match source title/author')
expected = cfg['source'].get('identifier')
if expected and expected not in dc_values(root, 'identifier'):
raise ValueError('Edition config source identifier mismatch')
def cover_item(root):
metadata = root.find(f'{{{OPF}}}metadata')
manifest = root.find(f'{{{OPF}}}manifest')
ids = {x.get('content') for x in metadata.findall(f'{{{OPF}}}meta') if x.get('name') == 'cover'}
images = [x for x in manifest if 'cover-image' in x.get('properties', '').split()]
if not images:
images = [x for x in manifest if x.get('id') in ids and x.get('media-type', '').startswith('image/')]
if len(images) > 1:
raise ValueError('Ambiguous cover image; repair source cover metadata first')
return images[0] if images else None
def prepare(epub, config, mode, work):
cfg = load_config(config); opf_path, root = read_package(epub); verify_binding(root, cfg)
work = Path(work); work.mkdir(parents=True, exist_ok=True)
item = cover_item(root); reference = None
if item is not None:
with zipfile.ZipFile(epub) as z:
name = archive_path(opf_path, item.get('href'))
reference = work/('original-cover' + Path(name).suffix)
reference.write_bytes(z.read(name))
job = {'source_sha256': sha(Path(epub).read_bytes()), 'mode': mode,
'title_zh': cfg['title_zh'], 'label': LABELS[mode],
'reference': str(reference.resolve()) if reference else None,
'prompt': f'Edit the supplied book cover (or create a restrained typographic cover if absent). Preserve its artwork and visual identity. Make the exact Chinese main title “{cfg.get("cover_title_zh", cfg["title_zh"])}” large and legible at library-thumbnail size. Use the exact Chinese subtitle “{cfg.get("cover_subtitle_zh", "")}” in smaller type. Keep the original English title smaller. Add a clearly visible badge with exactly “{LABELS[mode]} · AI译本” and a small revision “{cfg["edition_revision"]}”. Keep the author credit accurate. Do not invent a publisher, translator or official-edition claim. Render all Chinese characters precisely. Use a {"vermilion red" if mode == "target_only" else "navy blue"} version badge to distinguish the two modes at thumbnail size. Portrait cover, no mockup, no surrounding frame.'}
(work/'cover-job.json').write_text(json.dumps(job, ensure_ascii=False, indent=2))
return job
def image_type(data):
if data.startswith(b'\x89PNG\r\n\x1a\n'):
return 'image/png', '.png'
if data.startswith(b'\xff\xd8\xff'):
return 'image/jpeg', '.jpg'
raise ValueError('Cover must be PNG or JPEG (format detected from bytes)')
def remove_elements(metadata, elements):
ids = {x.get('id') for x in elements if x.get('id')}
while True:
related = [x for x in metadata if x.get('refines', '').lstrip('#') in ids]
expanded = ids | {x.get('id') for x in related if x.get('id')}
if expanded == ids: break
ids = expanded
for x in list(metadata):
if x in elements or x.get('refines', '').lstrip('#') in ids:
metadata.remove(x)
def set_values(metadata, key, values):
remove_elements(metadata, list(metadata.findall(f'{{{DC}}}{key}')))
for value in values:
etree.SubElement(metadata, f'{{{DC}}}{key}').text = value
def edition_record(root):
for x in root.findall(f'{{{OPF}}}metadata/{{{OPF}}}meta'):
if x.get('name') == RECORD_KEY or x.get('property') == RECORD_KEY:
return json.loads(x.get('content') if x.get('name') else x.text)
return None
def reviewed_cover(cover, review, cfg, mode):
image = Path(cover).read_bytes(); mime, suffix = image_type(image)
reviewed = json.loads(Path(review).read_text())
if (reviewed.get('cover_sha256') != sha(image) or reviewed.get('mode') != mode or
reviewed.get('title_zh') != cfg['title_zh'] or reviewed.get('edition_revision') != cfg['edition_revision'] or
reviewed.get('visual_review') != 'passed' or not reviewed.get('provider')):
raise ValueError('Cover review missing, stale or for another title/mode/revision')
return image, mime, suffix, reviewed
def localize(epub, output, config, mode, cover, review):
"""Keep original assets; add one reviewed cover and retarget cover references."""
epub, output, cover = Path(epub), Path(output), Path(cover)
if mode not in LABELS:
raise ValueError('Unknown edition mode')
if output.resolve() == epub.resolve():
raise ValueError('Output must not overwrite input')
cfg = load_config(config); opf_path, root = read_package(epub)
if edition_record(root):
raise ValueError('Already localized; use the untranslated-metadata EPUB for a fresh revision')
verify_binding(root, cfg)
image, mime, suffix, reviewed = reviewed_cover(cover, review, cfg, mode)
metadata = root.find(f'{{{OPF}}}metadata'); manifest = root.find(f'{{{OPF}}}manifest')
original = {'titles': dc_values(root, 'title'), 'authors': dc_values(root, 'creator'),
'identifiers': dc_values(root, 'identifier'), 'languages': dc_values(root, 'language'),
'publishers': dc_values(root, 'publisher'), 'dates': dc_values(root, 'date')}
old_cover = cover_item(root)
old_path = archive_path(opf_path, old_cover.get('href')) if old_cover is not None else None
title = f'{cfg["title_zh"]}【{LABELS[mode]} · {cfg["edition_revision"]}】'
record = {'schema': 1, 'mode': mode, 'title': title, 'title_zh': cfg['title_zh'],
'title_status': cfg['title_status'], 'evidence': cfg.get('evidence', []),
'edition_revision': cfg['edition_revision'], 'original': original,
'input_sha256': sha(epub.read_bytes()), 'cover_sha256': sha(image),
'authors': cfg.get('authors_zh', original['authors']),
'languages': ['zh-CN'] if mode == 'target_only' else list(dict.fromkeys(['zh-CN'] + original['languages'])),
'cover_bindings': [],
'image_generation': reviewed, 'config_sha256': sha(Path(config).read_bytes())}
identity = json.dumps(record, sort_keys=True, ensure_ascii=False)
identifier = 'urn:uuid:' + str(uuid.uuid5(uuid.NAMESPACE_URL, identity))
record['identifier'] = identifier
set_values(metadata, 'title', [title])
if cfg.get('authors_zh'):
if len(cfg['authors_zh']) != len(original['authors']):
raise ValueError('authors_zh must match the original author count')
set_values(metadata, 'creator', cfg['authors_zh'])
for creator in metadata.findall(f'{{{DC}}}creator'):
creator.set(f'{{{OPF}}}role', 'aut') if root.get('version', '').startswith('2') else None
set_values(metadata, 'language', record['languages'])
# Source ISBN and Calibre UUID must not identify this AI edition as the publisher's book.
set_values(metadata, 'identifier', [identifier])
identity_node = metadata.find(f'{{{DC}}}identifier')
used_ids = set(root.xpath('//@id')); new_id = 'translation-edition-id'
while new_id in used_ids: new_id += '-new'
identity_node.set('id', new_id); root.set('unique-identifier', new_id)
for value in original['identifiers']:
etree.SubElement(metadata, f'{{{DC}}}source').text = value
etree.SubElement(metadata, f'{{{DC}}}relation').text = 'Original title: ' + ' — '.join(original['titles'])
description = f'AI译本;{LABELS[mode]};版本 {cfg["edition_revision"]}。中文书名来源:' + ('正式出版译名(仅借用书名,本文件不是该出版译本)。' if cfg['title_status'] == 'authoritative' else '项目译名,尚未确认正式出版译名。')
etree.SubElement(metadata, f'{{{DC}}}description').text = description
# Publisher/date belong to the source, not the newly generated edition.
set_values(metadata, 'publisher', ['EPUB Bilingual Translator(AI译本)'])
set_values(metadata, 'date', [datetime.now(timezone.utc).strftime('%Y-%m-%d')])
for meta in list(metadata.findall(f'{{{OPF}}}meta')):
if meta.get('name') in {'calibre:title_sort', 'calibre:author_sort', 'calibre:timestamp'}:
metadata.remove(meta)
etree.SubElement(metadata, f'{{{OPF}}}meta', name='calibre:title_sort', content=title)
ids = {x.get('id') for x in manifest}; cover_id = 'translation-cover'
while cover_id in ids: cover_id += '-new'
additions = {}; replacements = {}
with zipfile.ZipFile(epub) as z:
href = 'translation-cover-' + sha(image)[:16] + suffix
image_path = archive_path(opf_path, href)
while image_path in z.namelist():
href = 'new-' + href; image_path = archive_path(opf_path, href)
record['cover_path'] = image_path; record['cover_id'] = cover_id
wrapper_targets = {}
for item in list(manifest):
props = item.get('properties', '').split()
if 'cover-image' in props:
props.remove('cover-image')
if props: item.set('properties', ' '.join(props))
else: item.attrib.pop('properties', None)
if old_path and item.get('media-type') in {'application/xhtml+xml', 'image/svg+xml'}:
path = archive_path(opf_path, item.get('href'))
doc = etree.fromstring(z.read(path), PARSER()); changed = False
for node in doc.iter():
if not isinstance(node.tag, str): continue
for attr in ('src', 'data', 'href', '{http://www.w3.org/1999/xlink}href'):
value = node.get(attr)
if value and etree.QName(node).localname in {'img', 'image', 'object'} and archive_path(path, value) == old_path:
node.set(attr, quote(posixpath.relpath(image_path, posixpath.dirname(path))))
if node.get('alt') is not None: node.set('alt', title)
if etree.QName(node).localname == 'img':
node.set('style', node.get('style', '').rstrip(';') + ';max-width:100%;height:auto;max-height:98vh;object-fit:contain')
if etree.QName(node).localname == 'image':
node.set('preserveAspectRatio', 'xMidYMid meet')
for ancestor in node.iterancestors():
if isinstance(ancestor.tag, str) and etree.QName(ancestor).localname == 'svg':
ancestor.set('preserveAspectRatio', 'xMidYMid meet')
changed = True
if changed:
data = etree.tostring(doc, encoding='utf-8', xml_declaration=True)
if item.get('media-type') == 'image/svg+xml':
# Clone SVG wrappers to keep every original image byte intact.
clone_href = posixpath.join(posixpath.dirname(item.get('href')), 'translation-wrapper-' + sha(data)[:16] + '.svg')
clone_path = archive_path(opf_path, clone_href)
while clone_path in z.namelist() or clone_path in additions:
clone_href = posixpath.join(posixpath.dirname(clone_href), 'new-' + posixpath.basename(clone_href))
clone_path = archive_path(opf_path, clone_href)
clone_id = cover_id + '-wrapper'
while clone_id in ids: clone_id += '-new'
ids.add(clone_id)
etree.SubElement(manifest, f'{{{OPF}}}item', id=clone_id, href=clone_href, **{'media-type':'image/svg+xml'})
additions[clone_path] = data; wrapper_targets[path] = clone_path
record['cover_bindings'].append({'file':clone_path, 'target':image_path})
else:
replacements[path] = data
record['cover_bindings'].append({'file':path, 'target':image_path})
if item.get('media-type') == 'application/x-dtbncx+xml':
path = archive_path(opf_path, item.get('href')); ncx = etree.fromstring(z.read(path), PARSER())
for node in ncx.xpath('//*[local-name()="meta" and @name="dtb:uid"]'): node.set('content', identifier)
for node in ncx.xpath('//*[local-name()="docTitle"]/*[local-name()="text"]'): node.text = title
replacements[path] = etree.tostring(ncx, encoding='utf-8', xml_declaration=True)
for item in manifest:
if item.get('media-type') != 'application/xhtml+xml' or not wrapper_targets: continue
path = archive_path(opf_path, item.get('href')); doc = etree.fromstring(replacements.get(path, z.read(path)), PARSER()); changed = False
for node in doc.iter():
if not isinstance(node.tag, str) or etree.QName(node).localname not in {'img','image','object'}: continue
for attr in ('src','data','href','{http://www.w3.org/1999/xlink}href'):
value = node.get(attr)
target = wrapper_targets.get(archive_path(path, value)) if value else None
if target:
node.set(attr, quote(posixpath.relpath(target, posixpath.dirname(path)))); changed = True
record['cover_bindings'].append({'file':path,'target':target})
if changed: replacements[path] = etree.tostring(doc, encoding='utf-8', xml_declaration=True)
attrs = {'id': cover_id, 'href': href, 'media-type': mime}
if root.get('version', '').startswith('3'): attrs['properties'] = 'cover-image'
etree.SubElement(manifest, f'{{{OPF}}}item', **attrs); additions[image_path] = image
cover_meta = [x for x in metadata.findall(f'{{{OPF}}}meta') if x.get('name') == 'cover']
for node in cover_meta: metadata.remove(node)
etree.SubElement(metadata, f'{{{OPF}}}meta', name='cover', content=cover_id)
if old_cover is None:
# A source with no raster cover gets a new front page without changing its TOC.
page_id = cover_id + '-page'; page_href = href + '.xhtml'
while page_id in ids: page_id += '-new'
page_path = archive_path(opf_path, page_href)
while page_path in z.namelist():
page_href = 'new-' + page_href; page_path = archive_path(opf_path, page_href)
page = etree.Element('{http://www.w3.org/1999/xhtml}html', nsmap={None: 'http://www.w3.org/1999/xhtml'})
head = etree.SubElement(page, '{http://www.w3.org/1999/xhtml}head'); etree.SubElement(head, '{http://www.w3.org/1999/xhtml}title').text = title
body = etree.SubElement(page, '{http://www.w3.org/1999/xhtml}body'); etree.SubElement(body, '{http://www.w3.org/1999/xhtml}img', src=href, alt=title, style='width:100%;height:auto;max-height:98vh;object-fit:contain')
additions[page_path] = etree.tostring(page, encoding='utf-8', xml_declaration=True)
record['cover_bindings'].append({'file':page_path, 'target':image_path})
etree.SubElement(manifest, f'{{{OPF}}}item', id=page_id, href=page_href, **{'media-type': 'application/xhtml+xml'})
root.find(f'{{{OPF}}}spine').insert(0, etree.Element(f'{{{OPF}}}itemref', idref=page_id, linear='yes'))
if root.get('version', '').startswith('3'):
prefix = root.get('prefix', '')
if 'translation:' in prefix: raise ValueError('Source already defines translation prefix')
root.set('prefix', (prefix + ' translation: ' + PREFIX).strip())
etree.SubElement(metadata, f'{{{OPF}}}meta', property=RECORD_KEY).text = json.dumps(record, ensure_ascii=False)
modified = [node for node in metadata.findall(f'{{{OPF}}}meta') if node.get('property') == 'dcterms:modified']
if not modified: modified = [etree.SubElement(metadata, f'{{{OPF}}}meta', property='dcterms:modified')]
for node in modified: node.text = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
else:
etree.SubElement(metadata, f'{{{OPF}}}meta', name=RECORD_KEY, content=json.dumps(record, ensure_ascii=False))
replacements[opf_path] = etree.tostring(root, encoding='utf-8', xml_declaration=True)
output.parent.mkdir(parents=True, exist_ok=True); temp = output.with_suffix(output.suffix + '.tmp')
try:
with zipfile.ZipFile(temp, 'w', compression=zipfile.ZIP_DEFLATED) as target:
target.writestr('mimetype', b'application/epub+zip', compress_type=zipfile.ZIP_STORED)
for info in z.infolist():
if info.filename != 'mimetype': target.writestr(info, replacements.get(info.filename, z.read(info.filename)))
for name, data in additions.items(): target.writestr(name, data)
temp.replace(output)
finally: temp.unlink(missing_ok=True)
return record
def audit_edition(epub):
"""Verify library-facing metadata and cover hash, including EPUB2 cover binding."""
opf_path, root = read_package(epub); record = edition_record(root)
if not record: return None
issues = []
if dc_values(root, 'title') != [record['title']]: issues.append('edition_title_mismatch')
if dc_values(root, 'creator') != record['authors']: issues.append('edition_author_mismatch')
if dc_values(root, 'language') != record['languages']: issues.append('edition_language_mismatch')
identifier = root.xpath('//*[local-name()="identifier" and @id=$id]/text()', id=root.get('unique-identifier'))
if identifier != [record['identifier']]: issues.append('edition_identifier_mismatch')
item = cover_item(root)
if item is None or item.get('id') != record['cover_id'] or archive_path(opf_path, item.get('href')) != record['cover_path']:
issues.append('edition_cover_binding_mismatch')
with zipfile.ZipFile(epub) as z:
if record['cover_path'] not in z.namelist() or sha(z.read(record['cover_path'])) != record['cover_sha256']:
issues.append('edition_cover_hash_mismatch')
for binding in record['cover_bindings']:
path = binding['file']
if path not in z.namelist():
issues.append('edition_cover_document_missing'); continue
doc = etree.fromstring(z.read(path), PARSER())
refs = [archive_path(path, value) for node in doc.iter() if isinstance(node.tag, str) and etree.QName(node).localname in {'img', 'image', 'object'}
for attr in ('src', 'data', 'href', '{http://www.w3.org/1999/xlink}href') if (value := node.get(attr))]
if binding['target'] not in refs: issues.append('edition_cover_document_binding_mismatch')
return {'title': record['title'], 'identifier': record['identifier'], 'mode': record['mode'],
'title_status': record['title_status'], 'evidence': record['evidence'], 'issues': issues}
def validate_config_assets(epub, config, mode):
config = Path(config); cfg = load_config(config)
_, root = read_package(epub); verify_binding(root, cfg)
asset = cfg.get('covers', {}).get(mode)
if not asset or not asset.get('image') or not asset.get('review'):
raise ValueError(f'No reviewed cover configured for {mode}; run localize_edition prepare first')
image, review = config.parent/asset['image'], config.parent/asset['review']
reviewed_cover(image, review, cfg, mode)
return image, review
def localize_from_config(epub, config, mode):
"""Optional pipeline hook; asset paths are relative to the edition JSON."""
image, review = validate_config_assets(epub, config, mode)
output = Path(epub).with_name('edition_' + Path(epub).name)
localize(epub, output, config, mode, image, review)
return output
+12 -2
View File
@@ -196,10 +196,14 @@ def coverage_report(manifest, source, structure=None):
def audit_pair(source, translated, manifest=None, structure=None):
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')