"""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'
Generated evidence; visual and semantic review remain pending. See report.json.