"""Compare cached source PDF pages and actual EPUB source anchors locally.""" import argparse import hashlib import json import shutil import sys from pathlib import Path from urllib.parse import quote sys.path.insert(0,str(Path(__file__).resolve().parents[1])) def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('epub',type=Path);parser.add_argument('--work',type=Path,required=True) parser.add_argument('--pages',default='1,4,16');parser.add_argument('--out',type=Path,required=True) args=parser.parse_args() from src.qa.headless import archive_server,book_inventory,LAYOUT_JS from src.qa.epub_audit import audit_epub from playwright.sync_api import sync_playwright data=json.loads((args.work/'book.json').read_text()) pages={p['number']:p for p in data['pages']};selected=[int(p) for p in args.pages.split(',')] if any(p not in pages for p in selected):parser.error('Requested page not present in the conversion IR') args.out.mkdir(parents=True,exist_ok=True) inventory=book_inventory(args.epub);evidence=[];clicks=[];sections=[] with archive_server({'book':args.epub}) as base,sync_playwright() as pw: browser=pw.chromium.launch(headless=True) try: context=browser.new_context(java_script_enabled=False,service_workers='block') context.route('**/*',lambda route:route.continue_() if route.request.url.startswith(base+'/') else route.abort()) tab=context.new_page() for number in selected: anchor=f'pdf-page-{number}' document=next((name for name,soup in inventory['documents'].items() if soup.find(id=anchor)),None) if not document:raise ValueError(f'EPUB lost source anchor {anchor}') source_name=f'pdf-{number:04d}.jpg';shutil.copy2(pages[number]['render'],args.out/source_name) rendered=[] for width in [900,390]: tab.set_viewport_size({'width':width,'height':1100}) tab.goto(base+'/book/'+quote(document)+'#'+anchor,wait_until='networkidle') tab.evaluate('() => document.fonts.ready') tab.evaluate('(id) => {const e=document.getElementById(id); scrollTo(0,Math.max(0,e.getBoundingClientRect().top+scrollY-140));}',anchor) name=f'epub-{number:04d}-{width}.png';tab.screenshot(path=str(args.out/name));rendered.append(name) evidence.append({'source_page':number,'document':document,'viewport_width':width,'image':name,'layout_findings':tab.evaluate(LAYOUT_JS)}) sections.append(f'

PDF 原页序 {number}

原扫描页EPUB实际排版窄屏排版
') # Real browser navigation for every generated TOC target, including fragments. for entry in inventory['toc']: target=entry['target'];name,_,fragment=target.partition('#') nav=entry['file'] nav_soup=inventory['documents'].get(nav) if nav_soup is not None: from src.qa.headless import destination anchors=nav_soup.find_all('a',href=True) link_index=next(i for i,a in enumerate(anchors) if destination(nav,a['href'])==target) tab.goto(base+'/book/'+quote(nav),wait_until='domcontentloaded') tab.locator('a[href]').nth(link_index).click(timeout=5000) else: tab.goto(base+'/book/'+quote(name)+(('#'+quote(fragment)) if fragment else ''),wait_until='domcontentloaded') reached=(tab.url.split('#')[0]==base+'/book/'+quote(name)) and (not fragment or tab.evaluate('(id) => !!document.getElementById(id)',fragment)) clicks.append({'target':target,'destination_reached':reached}) finally:browser.close() report={'source_sha256':data['source_sha256'],'epub_sha256':hashlib.sha256(args.epub.read_bytes()).hexdigest(),'epub_audit':audit_epub(args.epub),'evidence':evidence,'toc_navigation_checks':clicks,'visual_review':'pending','ocr_accuracy_review':'pending','limits':'Chromium continuous XHTML, not all reader pagination; source anchor may be inside a continued paragraph.'} (args.out/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n') (args.out/'review.html').write_text('PDF → EPUB QA

PDF → EPUB 对照

左:原扫描;中:EPUB;右:窄屏。截图生成不等于视觉或OCR验收。

'+''.join(sections)) print(f'PDF source comparisons: {len(selected)}; TOC target navigation: {len(clicks)}') return int(bool(report['epub_audit']['issues']) or any(not c['destination_reached'] for c in clicks) or any(e['layout_findings'] for e in evidence)) if __name__=='__main__':sys.exit(main())