423 lines
26 KiB
Python
423 lines
26 KiB
Python
"""Desktop-independent EPUB evidence: semantic links, coverage and optional Chromium."""
|
|
import collections
|
|
import hashlib
|
|
import html
|
|
import json
|
|
import mimetypes
|
|
import posixpath
|
|
import re
|
|
import threading
|
|
import unicodedata
|
|
import zipfile
|
|
from contextlib import contextmanager
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import quote, unquote, urlsplit
|
|
from lxml import etree
|
|
from src.common.html import parse_html
|
|
from src.assembly.package_writer import package_path
|
|
from src.edition.fonts import decoded_fonts
|
|
from src.assembly.format_restorer import FormatRestorer
|
|
from src.common.data_model import ManifestEntry
|
|
from src.qa.epub_audit import audit_epub, compare_audits
|
|
|
|
|
|
def destination(document, href):
|
|
url = urlsplit(href)
|
|
if url.scheme or url.netloc:
|
|
return None
|
|
path = posixpath.normpath(posixpath.join(posixpath.dirname(document), unquote(url.path))) if url.path else document
|
|
return path + ('#' + unquote(url.fragment) if url.fragment else '')
|
|
|
|
|
|
def book_inventory(path):
|
|
with zipfile.ZipFile(path) as z:
|
|
opf_path = package_path(z)
|
|
root = etree.fromstring(z.read(opf_path), etree.XMLParser(resolve_entities=False, no_network=True))
|
|
items = root.xpath('//*[local-name()="manifest"]/*')
|
|
resources = {}
|
|
docs = {}
|
|
toc = []
|
|
label_variants = []
|
|
for item in items:
|
|
name = destination(opf_path, item.get('href', ''))
|
|
if not name or name not in z.namelist():
|
|
continue
|
|
resources[item.get('id')] = name
|
|
if item.get('media-type') == 'application/xhtml+xml':
|
|
soup = parse_html(z.read(name).decode('utf-8', errors='replace'))
|
|
docs[name] = soup
|
|
from src.preprocessing.navigation import is_toc
|
|
if is_toc(soup, name, item.get('properties', '')):
|
|
label_variants.extend({'file': name, 'label': a.get_text(' ', strip=True),
|
|
'target': destination(name, a['href'])} for a in soup.find_all('a', href=True))
|
|
for nav in soup.find_all('nav'):
|
|
if 'toc' not in str(nav.get('epub:type', '')).split() and nav.get('role') != 'doc-toc':
|
|
continue
|
|
for a in nav.find_all('a', href=True):
|
|
toc.append({'file': name, 'label': a.get_text(' ', strip=True),
|
|
'target': destination(name, a['href']),
|
|
'depth': len(a.find_parents('ol'))})
|
|
if not toc:
|
|
for item in items:
|
|
if item.get('media-type') != 'application/x-dtbncx+xml':
|
|
continue
|
|
name = resources.get(item.get('id'))
|
|
if not name:
|
|
continue
|
|
ncx = etree.fromstring(z.read(name), etree.XMLParser(resolve_entities=False, no_network=True))
|
|
for node in ncx.xpath('//*[local-name()="navMap"]//*[local-name()="navPoint"]'):
|
|
labels = node.xpath('./*[local-name()="navLabel"]/*[local-name()="text"]')
|
|
hrefs = node.xpath('./*[local-name()="content"]/@src')
|
|
if hrefs:
|
|
toc.append({'file': name, 'label': ''.join(labels[0].itertext()) if labels else '',
|
|
'target': destination(name, hrefs[0]),
|
|
'depth': len(node.xpath('ancestor::*[local-name()="navPoint"]')) + 1})
|
|
for item in items:
|
|
if item.get('media-type') == 'application/x-dtbncx+xml' and resources.get(item.get('id')):
|
|
name = resources[item.get('id')]
|
|
ncx = etree.fromstring(z.read(name), etree.XMLParser(resolve_entities=False, no_network=True))
|
|
for node in ncx.xpath('//*[local-name()="navMap"]//*[local-name()="navPoint"]'):
|
|
labels = node.xpath('./*[local-name()="navLabel"]/*[local-name()="text"]')
|
|
hrefs = node.xpath('./*[local-name()="content"]/@src')
|
|
if hrefs and labels:
|
|
label_variants.append({'file': name, 'label': ''.join(labels[0].itertext()), 'target': destination(name, hrefs[0])})
|
|
spine = [resources[r] for r in root.xpath('//*[local-name()="spine"]/*/@idref') if r in resources]
|
|
binaries = {n: hashlib.sha256(z.read(n)).hexdigest() for n in z.namelist()
|
|
if n.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.svg', '.ttf', '.otf', '.woff', '.woff2'))}
|
|
return {'documents': docs, 'toc': toc, 'spine': spine, 'binaries': binaries,
|
|
'package_base': posixpath.dirname(opf_path), 'label_variants': label_variants}
|
|
|
|
|
|
def plain_key(text):
|
|
return ''.join(c for c in text if not c.isspace() and not unicodedata.category(c).startswith('P')).casefold()
|
|
|
|
|
|
def is_noteref(node):
|
|
return (node.get('role') == 'doc-noteref' or 'noteref' in str(node.get('epub:type', '')).split()
|
|
or bool(re.search(r'(?:foot|end)note[-_]?(?:ref|reference)', ' '.join(node.get('class', [])), re.I)))
|
|
|
|
|
|
def notes_report(inventory):
|
|
refs = []
|
|
docs = inventory['documents']
|
|
for name, soup in docs.items():
|
|
for a in soup.find_all('a', href=True):
|
|
if not is_noteref(a):
|
|
continue
|
|
target = destination(name, a['href'])
|
|
record = {'file': name, 'href': a['href'], 'target': target}
|
|
if target is None:
|
|
record['classification'] = 'external_note'
|
|
else:
|
|
file, _, fragment = target.partition('#')
|
|
doc = docs.get(file)
|
|
node = doc.find(id=fragment) if doc and fragment else doc.body if doc else None
|
|
if node is None:
|
|
record['classification'] = 'missing_target'
|
|
else:
|
|
semantic = any(re.search(r'footnote|endnote|doc-note',
|
|
str(n.get('role', '')) + ' ' + str(n.get('epub:type', '')) + ' ' + ' '.join(n.get('class', [])), re.I)
|
|
for n in [node, *node.parents] if getattr(n, 'attrs', None))
|
|
urls = [link['href'] for link in doc.find_all('a', href=True)
|
|
if urlsplit(link['href']).scheme in {'http', 'https'} and re.search(r'notes?', link['href'], re.I)]
|
|
record['classification'] = 'embedded_note' if semantic else 'external_notes_gateway' if urls else 'non_note_target'
|
|
if urls:
|
|
record['external_urls'] = sorted(set(urls))
|
|
refs.append(record)
|
|
counts = collections.Counter(r['classification'] for r in refs)
|
|
groups = collections.Counter(r['target'] for r in refs)
|
|
findings = []
|
|
for target, count in groups.items():
|
|
matching = [r for r in refs if r['target'] == target]
|
|
if count >= 3 and any(r['classification'] in {'non_note_target', 'external_notes_gateway'} for r in matching):
|
|
findings.append({'kind': 'concentrated_note_gateway', 'target': target, 'count': count,
|
|
'classification': matching[0]['classification'],
|
|
'detail': 'References lead to a shared non-note page; verify external note policy against source.'})
|
|
return {'counts': dict(counts), 'findings': findings, 'references': refs}
|
|
|
|
|
|
def compare_navigation(before, after):
|
|
a = [(n['target'], n['depth']) for n in before['toc']]
|
|
b = [(n['target'], n['depth']) for n in after['toc']]
|
|
return [] if a == b else [{'kind': 'navigation_changed', 'detail': 'TOC destinations, order or hierarchy differ from source'}]
|
|
|
|
|
|
def navigation_markup(inventory):
|
|
findings = []
|
|
for name, doc in inventory['documents'].items():
|
|
for nav in doc.find_all('nav'):
|
|
if 'toc' not in str(nav.get('epub:type', '')).split() and nav.get('role') != 'doc-toc':
|
|
continue
|
|
for a in nav.find_all('a', href=True):
|
|
if a.parent.name != 'li':
|
|
findings.append({'kind': 'nav_link_not_direct_li', 'file': name, 'detail': a['href']})
|
|
return findings
|
|
|
|
|
|
def coverage_report(manifest, source, structure=None):
|
|
entries = [ManifestEntry.model_validate(e) for e in json.loads(Path(manifest).read_text())]
|
|
ids = [e.entry_id for e in entries]
|
|
failures = []
|
|
restorer = FormatRestorer()
|
|
for e in entries:
|
|
errors = list(e.validation_errors)
|
|
if not e.translated_text or not e.translated_html:
|
|
errors.append('missing translation/restored HTML')
|
|
elif restorer.validate(e.translated_text, e.placeholders, e.original_text):
|
|
errors.append('placeholder validation failed')
|
|
elif not restorer.restore(e.translated_text, e.placeholders, original_text=e.original_text)[1]:
|
|
errors.append('restoration failed')
|
|
if errors:
|
|
failures.append({'entry_id': e.entry_id, 'errors': errors})
|
|
provenance = 'unverified'
|
|
missing_ids = []; extra_ids = []; mismatched_ids = []
|
|
if structure:
|
|
data = json.loads(Path(structure).read_text())
|
|
provenance = 'matched' if data.get('source_sha256') == hashlib.sha256(Path(source).read_bytes()).hexdigest() else 'mismatch'
|
|
if provenance == 'matched':
|
|
from src.common.data_model import BookStructure
|
|
from src.preprocessing.text_extractor import FineGrainedExtractor
|
|
from src.preprocessing.navigation import prepare_navigation
|
|
book = BookStructure.model_validate(data)
|
|
expected = FineGrainedExtractor(complete_book=True).extract(book)
|
|
prepare_navigation(source, book, expected)
|
|
originals = {e.entry_id: e for e in expected}
|
|
actual = {e.entry_id: e for e in entries}
|
|
missing_ids = sorted(originals.keys() - actual.keys())
|
|
extra_ids = sorted(actual.keys() - originals.keys())
|
|
mismatched_ids = [key for key in originals.keys() & actual.keys()
|
|
if (originals[key].original_text, originals[key].placeholders) !=
|
|
(actual[key].original_text, actual[key].placeholders)]
|
|
duplicates = [i for i, n in collections.Counter(ids).items() if n > 1]
|
|
return {'total': len(entries), 'accepted': len(entries) - len(failures), 'failed': failures,
|
|
'duplicate_ids': duplicates, 'source_provenance': provenance,
|
|
'missing_ids': missing_ids, 'extra_ids': extra_ids, 'mismatched_ids': mismatched_ids,
|
|
'scope': 'complete-book extraction' if structure and provenance == 'matched' else 'provided manifest only'}
|
|
|
|
|
|
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():
|
|
if after['binaries'].get(name) != digest:
|
|
issues.append({'kind': 'binary_resource_changed', 'file': name, 'detail': 'Image/font missing or changed'})
|
|
sn, tn = notes_report(before), notes_report(after)
|
|
source_refs = {(r['file'], r['href'], r['classification']) for r in sn['references']}
|
|
for ref in tn['references']:
|
|
if ref['classification'] in {'missing_target', 'non_note_target', 'external_notes_gateway'} and (ref['file'], ref['href'], ref['classification']) not in source_refs:
|
|
issues.append({'kind': 'new_note_destination_problem', **ref})
|
|
coverage = coverage_report(manifest, source, structure) if manifest else None
|
|
if coverage and (not coverage['total'] or coverage['failed'] or coverage['duplicate_ids'] or
|
|
coverage['missing_ids'] or coverage['extra_ids'] or coverage['mismatched_ids'] or coverage['source_provenance'] == 'mismatch'):
|
|
issues.append({'kind': 'incomplete_or_mismatched_manifest', 'detail': 'Inspect coverage'})
|
|
if manifest:
|
|
entries = [ManifestEntry.model_validate(x) for x in json.loads(Path(manifest).read_text())]
|
|
records = json.loads(Path(structure).read_text()).get('navigation_titles', []) if structure else []
|
|
record_by_id = {r['entry_id']: r for r in records}
|
|
for e in entries:
|
|
if e.file_path == '__navigation__' or not e.translated_text:
|
|
continue
|
|
name = e.file_path if e.file_path in after['documents'] else posixpath.normpath(posixpath.join(after['package_base'], e.file_path))
|
|
doc = after['documents'].get(name)
|
|
node = doc.find(id=e.element_id) if doc else None
|
|
translated_node = doc.find(attrs={'data-translation-for': e.element_id}) if doc else None
|
|
if not translated_node and not (node and 'translation' in node.get('class', [])):
|
|
issues.append({'kind': 'translation_not_backfilled', 'file': e.file_path, 'entry_id': e.entry_id})
|
|
else:
|
|
from src.preprocessing.navigation import normalize_heading_html
|
|
expected, valid = FormatRestorer().restore(e.translated_text, e.placeholders, original_text=e.original_text)
|
|
if valid:
|
|
if e.entry_id in record_by_id:
|
|
expected = normalize_heading_html(record_by_id[e.entry_id], expected)
|
|
expected_text = re.sub(r'\s+', '', parse_html(expected).get_text())
|
|
actual_text = re.sub(r'\s+', '', (translated_node or node).get_text())
|
|
if expected_text != actual_text:
|
|
issues.append({'kind': 'translation_content_mismatch', 'file': e.file_path, 'entry_id': e.entry_id})
|
|
if records:
|
|
from src.common.data_model import BookStructure
|
|
from src.preprocessing.navigation import navigation_labels
|
|
expected_labels = navigation_labels(BookStructure.model_validate(json.loads(Path(structure).read_text())), entries)
|
|
for target, label in expected_labels.items():
|
|
file, _, frag = target.partition('#')
|
|
archive_file = file if file in after['documents'] else posixpath.normpath(posixpath.join(after['package_base'], file))
|
|
key = archive_file + ('#' + frag if frag else '')
|
|
for variant in after['label_variants']:
|
|
if variant['target'] == key and not plain_key(variant['label']).endswith(plain_key(label)):
|
|
issues.append({'kind': 'navigation_title_mismatch', **variant, 'expected': label})
|
|
return {'source': str(source), 'translated': str(translated),
|
|
'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, '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
|
|
|
|
|
|
@contextmanager
|
|
def archive_server(paths):
|
|
"""Serve allowlisted ZIP members on loopback, without extracting executable book data."""
|
|
archives = {key: zipfile.ZipFile(path) for key, path in paths.items()}
|
|
fonts = {}
|
|
try:
|
|
for key, archive in archives.items():
|
|
opf_path = package_path(archive)
|
|
root = etree.fromstring(archive.read(opf_path), etree.XMLParser(resolve_entities=False, no_network=True))
|
|
fonts[key] = decoded_fonts(archive, opf_path, root)
|
|
except Exception:
|
|
for archive in archives.values(): archive.close()
|
|
raise
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
def do_GET(self):
|
|
prefix, _, name = unquote(urlsplit(self.path).path).lstrip('/').partition('/')
|
|
archive = archives.get(prefix)
|
|
if not archive or name not in archive.namelist() or name.endswith('/'):
|
|
self.send_error(404); return
|
|
data = fonts[prefix].get(name)
|
|
if data is None: data = archive.read(name)
|
|
mime = 'text/html' if name.lower().endswith(('.xhtml', '.html', '.htm')) else mimetypes.guess_type(name)[0] or 'application/octet-stream'
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', mime + ('; charset=utf-8' if mime.startswith('text/') else ''))
|
|
self.send_header('Content-Security-Policy', "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; script-src 'none'; object-src 'none'")
|
|
self.end_headers(); self.wfile.write(data)
|
|
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
|
|
try:
|
|
yield f'http://127.0.0.1:{server.server_port}'
|
|
finally:
|
|
server.shutdown(); server.server_close(); thread.join()
|
|
for archive in archives.values(): archive.close()
|
|
|
|
|
|
LAYOUT_JS = """() => {
|
|
const findings=[];
|
|
for(const e of document.querySelectorAll('img, p, h1, h2, h3, h4, table, [data-translation-ordinal]')) {
|
|
const r=e.getBoundingClientRect(), s=getComputedStyle(e);
|
|
if(!r.width || !r.height) continue;
|
|
const id=e.dataset.translationFor || e.id || e.tagName + ':' + [...e.parentElement.children].indexOf(e);
|
|
if(r.right > innerWidth+3 || r.left < -3) findings.push({kind:'horizontal_overflow',element:id});
|
|
if(e.tagName==='IMG' && !e.naturalWidth) findings.push({kind:'broken_image',element:id});
|
|
if(e.dataset.translationRole==='body' && Math.abs(parseFloat(s.textIndent)/parseFloat(s.fontSize)-2)>.1)
|
|
findings.push({kind:'chinese_indent',element:id});
|
|
if(e.dataset.translationOrdinal && Math.abs(parseFloat(s.fontSize)/parseFloat(getComputedStyle(e.parentElement).fontSize)-1)>.1)
|
|
findings.push({kind:'ordinal_font_size',element:id});
|
|
}
|
|
return findings;
|
|
}"""
|
|
|
|
|
|
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),
|
|
lambda n: any(is_noteref(a) for a in after['documents'][n].find_all('a')),
|
|
lambda n: after['documents'][n].find('img'),
|
|
lambda n: re.search(r'note|credit|index|bibli', n, re.I)):
|
|
match = next((n for n in candidates if n not in risky and predicate(n)), None)
|
|
if match: risky.append(match)
|
|
selected = chapters or (risky + [n for n in candidates if n not in risky])[:max_documents]
|
|
if not selected:
|
|
raise ValueError('No documents selected for rendering')
|
|
for name in selected:
|
|
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:
|
|
browser = p.chromium.launch(headless=True)
|
|
browser_version = browser.version
|
|
try:
|
|
context = browser.new_context(viewport={'width': width, 'height': height}, java_script_enabled=False, service_workers='block')
|
|
context.route('**/*', lambda route: route.continue_() if route.request.url.startswith(base + '/') else route.abort())
|
|
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')
|
|
findings = page.evaluate(LAYOUT_JS)
|
|
layout.extend({'book': label, 'file': name, **f} for f in findings)
|
|
total_height = page.evaluate('() => document.documentElement.scrollHeight')
|
|
for n in range(min(pages, max(1, (total_height + height - 1)//height))):
|
|
page.evaluate('(y) => scrollTo(0,y)', n * height)
|
|
png = out / f'{index:03d}-{label}-{n+1}.png'
|
|
page.screenshot(path=str(png))
|
|
evidence.append({'file': name, 'book': label, 'page': n+1, 'image': png.name})
|
|
# Check one real internal note navigation per selected document.
|
|
soup = (before if label == 'source' else after)['documents'][name]
|
|
links = soup.find_all('a')
|
|
for i, a in enumerate(links):
|
|
if not a.get('href') or not is_noteref(a) or destination(name, a['href']) is None:
|
|
continue
|
|
page.goto(url, wait_until='networkidle')
|
|
page.locator('a').nth(i).click(timeout=5000)
|
|
expected = destination(name, a['href'])
|
|
target, _, fragment = expected.partition('#')
|
|
actual = unquote(urlsplit(page.url).path).removeprefix('/' + label + '/')
|
|
found = not fragment or page.evaluate('(id) => !!document.getElementById(id)', fragment)
|
|
classification = next(r['classification'] for r in notes_report(before if label == 'source' else after)['references']
|
|
if r['file'] == name and r['href'] == a['href'])
|
|
reached = actual == target and found
|
|
click = {'book': label, 'file': name, 'href': a['href'], 'target': expected,
|
|
'destination_reached': reached, 'classification': classification,
|
|
'embedded_note_reached': reached and classification == 'embedded_note'}
|
|
if reached and classification == 'embedded_note':
|
|
target_doc = (before if label == 'source' else after)['documents'][target]
|
|
target_node = target_doc.find(id=fragment) if fragment else target_doc.body
|
|
block = target_node.parent if target_node.name == 'a' else target_node
|
|
back = next((link for link in block.find_all('a', href=True)
|
|
if (destination(target, link['href']) or '').partition('#')[0] == name), None)
|
|
if back:
|
|
link_index = target_doc.find_all('a').index(back)
|
|
page.locator('a').nth(link_index).click(timeout=5000)
|
|
back_dest = destination(target, back['href'])
|
|
back_file, _, back_fragment = back_dest.partition('#')
|
|
actual_back = unquote(urlsplit(page.url).path).removeprefix('/' + label + '/')
|
|
click['backlink_reached'] = actual_back == back_file and (not back_fragment or page.evaluate('(id) => !!document.getElementById(id)', back_fragment))
|
|
else:
|
|
click['backlink_reached'] = None
|
|
clicks.append(click)
|
|
break
|
|
context.close()
|
|
finally:
|
|
browser.close()
|
|
source_flags = {(f['file'], f['kind'], f['element']) for f in layout if f['book'] == 'source'}
|
|
return {'engine': 'Chromium headless continuous XHTML; not reader-specific pagination',
|
|
'browser_version': browser_version,
|
|
'viewport': [width, height], 'sampled_documents': selected, 'images': evidence,
|
|
'layout_findings': layout,
|
|
'new_layout_findings': [f for f in layout if f['book'] == 'translated' and (f['file'], f['kind'], f['element']) not in source_flags],
|
|
'note_clicks': clicks, 'visual_review': 'pending'}
|
|
|
|
|
|
def write_review(out, report):
|
|
images = report.get('render', {}).get('images', [])
|
|
indexed = {(x['file'], x['page'], x['book']): x for x in images}
|
|
rows = ''
|
|
for file in dict.fromkeys(x['file'] for x in images):
|
|
for page in sorted({x['page'] for x in images if x['file'] == file}):
|
|
for book in ('source', 'translated'):
|
|
x = indexed.get((file, page, book))
|
|
rows += (f'<figure><img loading="lazy" src="{html.escape(x["image"])}"><figcaption>{html.escape(file)} — {book} {page}</figcaption></figure>'
|
|
if x else '<figure>No corresponding viewport</figure>')
|
|
(out/'review.html').write_text('<!doctype html><meta charset="utf-8"><title>Headless EPUB QA</title>'
|
|
'<style>main{display:grid;grid-template-columns:1fr 1fr;gap:1em}img{width:100%}</style>'
|
|
'<h1>Headless EPUB QA</h1><p>Generated evidence; visual and semantic review remain pending. See report.json.</p><main>'+rows+'</main>')
|