"""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 and e.file_path != "__navigation__")) 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'
{group[index].name}
' if index
No corresponding page
') (args.out/'review.html').write_text('EPUB visual QA' '

EPUB visual QA

结构检查见 report.json。截图须人工/agent检查;生成截图不等于视觉通过。

' '
'+rows+'
') 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())