145 lines
8.4 KiB
Python
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 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'<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())
|