Files
epub_bilingual_translator/scripts/visual_qa.py
T

145 lines
8.4 KiB
Python

"""Repeatable EPUB structural audit + Calibre render evidence.
Rendering is evidence for visual inspection, never an automatic visual pass.
"""
import argparse
import json
import os
import posixpath
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
from lxml import etree
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.qa.epub_audit import audit_epub, compare_audits
from src.assembly.package_writer import package_path
from src.common.data_model import ManifestEntry
from src.assembly.format_restorer import FormatRestorer
def subset_epub(epub, output, chapter_hrefs):
with zipfile.ZipFile(epub) as source:
opf_path = package_path(source)
opf = etree.fromstring(source.read(opf_path))
selected = {item.get('id') for item in opf.xpath('//*[local-name()="manifest"]/*')
if item.get('href') in chapter_hrefs}
if not selected:
raise ValueError('No selected chapters found in package')
manifest = opf.xpath('//*[local-name()="manifest"]')[0]
namespace = etree.QName(manifest).namespace
for item in list(manifest):
if (item.get('media-type') in {'application/xhtml+xml', 'application/x-dtbncx+xml'}
and item.get('id') not in selected):
manifest.remove(item)
elif item.get('properties') == 'cover-image':
item.attrib.pop('properties', None)
# Calibre includes non-spine HTML too, so remove unused documents from
# the sample manifest and provide a sample-only navigation document.
nav_href = '__qa_nav.xhtml'
etree.SubElement(manifest, f'{{{namespace}}}item', id='qa_nav', href=nav_href,
properties='nav', **{'media-type':'application/xhtml+xml'})
nav = etree.Element('{http://www.w3.org/1999/xhtml}html',
nsmap={None:'http://www.w3.org/1999/xhtml', 'epub':'http://www.idpf.org/2007/ops'})
head=etree.SubElement(nav,'{http://www.w3.org/1999/xhtml}head')
etree.SubElement(head,'{http://www.w3.org/1999/xhtml}title').text='QA sample'
body=etree.SubElement(nav,'{http://www.w3.org/1999/xhtml}body')
toc=etree.SubElement(body,'{http://www.w3.org/1999/xhtml}nav',
**{'{http://www.idpf.org/2007/ops}type':'toc'})
listing=etree.SubElement(toc,'{http://www.w3.org/1999/xhtml}ol')
for href in sorted(chapter_hrefs):
li=etree.SubElement(listing,'{http://www.w3.org/1999/xhtml}li')
etree.SubElement(li,'{http://www.w3.org/1999/xhtml}a',href=href).text=Path(href).stem
spine = opf.xpath('//*[local-name()="spine"]')[0]
spine.attrib.pop('toc', None)
for ref in list(spine):
if ref.get('idref') not in selected:
spine.remove(ref)
for node in opf.xpath('//*[local-name()="guide"] | //*[local-name()="meta"][@name="cover"]'):
node.getparent().remove(node)
with zipfile.ZipFile(output, 'w') as target:
kept_documents = {posixpath.normpath(posixpath.join(posixpath.dirname(opf_path), href)) for href in chapter_hrefs}
for info in source.infolist():
if info.filename.lower().endswith(('.xhtml', '.html', '.htm', '.ncx')) and info.filename not in kept_documents:
continue
target.writestr(info, etree.tostring(opf, encoding='utf-8', xml_declaration=True)
if info.filename == opf_path else source.read(info.filename))
target.writestr(posixpath.join(posixpath.dirname(opf_path),nav_href),etree.tostring(nav))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('source', type=Path)
parser.add_argument('translated', type=Path)
parser.add_argument('--manifest', type=Path)
parser.add_argument('--out', type=Path, default=Path('.work/visual-qa'))
parser.add_argument('--render', action='store_true', help='Calibre PDF + Poppler PNGs for visual review')
parser.add_argument('--pages', type=int, default=6)
parser.add_argument('--chapter', action='append', help='Exact EPUB chapter href; repeat to select several')
parser.add_argument('--max-chapters', type=int, default=3, help='Default visual sampling limit; 0 renders all')
parser.add_argument('--allow-partial', action='store_true', help='Explicitly permit sample coverage')
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
source, output = audit_epub(args.source), audit_epub(args.translated)
report = {'source': source, 'output': output, 'new_issues': compare_audits(source, output),
'visual_review': 'pending', 'coverage': None}
entries = []
if args.manifest:
entries = [ManifestEntry.model_validate(e) for e in json.loads(args.manifest.read_text())]
errors = []
for entry in entries:
html, ok = FormatRestorer().restore(entry.translated_text, entry.placeholders, original_text=entry.original_text)
if not entry.translated_text or not ok or not entry.translated_html or entry.validation_errors:
errors.append(entry.entry_id)
report['coverage'] = {'total': len(entries), 'accepted': len(entries) - len(errors), 'failed_ids': errors,
'scope': 'provided manifest only'}
if errors and not args.allow_partial:
report['new_issues'].append({'kind': 'incomplete_translation', 'detail': str(len(errors))})
chapter_hrefs = args.chapter or list(dict.fromkeys(e.file_path for e in entries if e.translated_text))
if not args.chapter and args.max_chapters:
chapter_hrefs = chapter_hrefs[:args.max_chapters]
report['sampled_chapters'] = chapter_hrefs
if args.render:
for tool in ('ebook-convert', 'pdftoppm'):
if not shutil.which(tool):
raise RuntimeError(f'Missing QA dependency: {tool}')
for label, path in [('source', args.source), ('translated', args.translated)]:
sample = args.out / f'{label}-sample.epub'
if chapter_hrefs:
subset_epub(path, sample, set(chapter_hrefs))
else:
shutil.copyfile(path, sample)
for old in args.out.glob(label+'-*.png'):
old.unlink()
pdf = args.out / f'{label}.pdf'
command = ['ebook-convert', str(sample.resolve()), str(pdf.resolve()),
'--pdf-serif-family', 'Songti SC', '--pdf-sans-family', 'PingFang SC',
'--pdf-default-font-size', '14', '--paper-size', 'a4']
env = os.environ.copy()
env['CALIBRE_CONFIG_DIRECTORY'] = str((args.out/'calibre-config').resolve())
(args.out/'calibre-config').mkdir(exist_ok=True)
with (args.out/f'{label}-calibre.log').open('w') as log:
subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=240, env=env)
subprocess.run(['pdftoppm', '-f', '1', '-l', str(args.pages), '-scale-to', '1400', '-png',
str(pdf), str(args.out/label)], check=True, timeout=120)
report['rendered_pages'] = sorted(p.name for p in args.out.glob('*.png'))
(args.out/'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2))
import re
def pages(label):
return sorted(args.out.glob(label+'-*.png'), key=lambda p: int(re.search(r'-(\d+)\.png$', p.name)[1]))
source_pages, target_pages = pages('source'), pages('translated')
rows = ''
for index in range(max(len(source_pages),len(target_pages))):
for group in (source_pages,target_pages):
rows += (f'<figure><img src="{group[index].name}" style="width:100%"><figcaption>{group[index].name}</figcaption></figure>'
if index<len(group) else '<figure><figcaption>No corresponding page</figcaption></figure>')
(args.out/'review.html').write_text('<!doctype html><meta charset="utf-8"><title>EPUB visual QA</title>'
'<h1>EPUB visual QA</h1><p>结构检查见 report.json。截图须人工/agent检查;生成截图不等于视觉通过。</p>'
'<main style="display:grid;grid-template-columns:1fr 1fr;gap:20px">'+rows+'</main>')
print(f"New structural issues: {len(report['new_issues'])}; visual review: pending; {args.out/'report.json'}")
return 1 if report['new_issues'] else 0
if __name__ == '__main__':
raise SystemExit(main())