Add Chinese covers and source-bound library edition metadata
This commit is contained in:
@@ -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):
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user