201 lines
14 KiB
Python
201 lines
14 KiB
Python
"""Reader-facing edition identity and cover binding, EPUB2/3 and missing-cover cases."""
|
|
import base64
|
|
import json
|
|
import zipfile
|
|
from pathlib import Path
|
|
import pytest
|
|
from lxml import etree
|
|
from src.edition.localize import localize, prepare, sha, read_package, dc_values, audit_edition, edition_record, localize_from_config
|
|
from src.qa.headless import audit_pair
|
|
|
|
PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=')
|
|
OLD = PNG + b'original'
|
|
|
|
|
|
def fixture(tmp_path, version='3.0', has_cover=True, svg=False):
|
|
path = tmp_path/'input.epub'
|
|
cover_meta = '<meta name="cover" content="old-cover"/>' if has_cover else ''
|
|
cover_item = '<item id="old-cover" href="images/old.png" media-type="image/png"' + (' properties="cover-image"' if version == '3.0' else '') + '/>' if has_cover else ''
|
|
cover_doc = '<!-- a publisher comment --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="images/old.png"/></svg>' if svg else '<img src="images/old.png" alt="old title"/>'
|
|
opf = f'''<package xmlns="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/" version="{version}" unique-identifier="old-id"><metadata><dc:title id="title">Original Work</dc:title><dc:creator id="author">An Author</dc:creator><dc:identifier id="old-id">9780000000001</dc:identifier><dc:language>en</dc:language><dc:publisher>Original Publisher</dc:publisher>{cover_meta}<meta name="calibre:title_sort" content="Original Work"/><meta property="title-type" refines="#title">main</meta><meta property="file-as" refines="#author">Author, An</meta></metadata><manifest>{cover_item}<item id="cover-page" href="cover.xhtml" media-type="application/xhtml+xml"/><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/><item id="toc" href="toc.ncx" media-type="application/x-dtbncx+xml"/><item id="art" href="images/body.png" media-type="image/png"/></manifest><spine toc="toc"><itemref idref="cover-page"/><itemref idref="chapter"/></spine></package>'''
|
|
with zipfile.ZipFile(path, 'w') as z:
|
|
z.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
|
|
z.writestr('META-INF/container.xml', '<container><rootfiles><rootfile full-path="EPUB/package.opf"/></rootfiles></container>')
|
|
z.writestr('EPUB/package.opf', opf)
|
|
z.writestr('EPUB/cover.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Cover</title></head><body>'+ (cover_doc if has_cover else '<p>Title page</p>') + '</body></html>')
|
|
z.writestr('EPUB/chapter.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body><h1 id="chapter">Chapter</h1><p>Keep my text and illustration.</p><img src="images/body.png"/></body></html>')
|
|
z.writestr('EPUB/toc.ncx', '<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><head><meta name="dtb:uid" content="9780000000001"/></head><docTitle><text>Original Work</text></docTitle><navMap><navPoint id="ch"><navLabel><text>Chapter</text></navLabel><content src="chapter.xhtml#chapter"/></navPoint></navMap></ncx>')
|
|
z.writestr('EPUB/images/body.png', OLD)
|
|
if has_cover: z.writestr('EPUB/images/old.png', OLD)
|
|
cfg = tmp_path/'edition.json'
|
|
config = {'source': {'titles':['Original Work'], 'authors':['An Author'], 'identifier':'9780000000001'},
|
|
'title_zh':'权威书名', 'authors_zh':['作者译名'], 'title_status':'authoritative',
|
|
'evidence':[{'url':'https://publisher.example/book'}], 'edition_revision':'luna-r2'}
|
|
cfg.write_text(json.dumps(config)); image = tmp_path/'generated.any'; image.write_bytes(PNG)
|
|
review = tmp_path/'review.json'
|
|
def review_for(mode):
|
|
review.write_text(json.dumps({'cover_sha256':sha(PNG), 'mode':mode, 'title_zh':config['title_zh'],
|
|
'edition_revision':'luna-r2', 'visual_review':'passed', 'provider':'test fixture', 'model':None}))
|
|
review_for('target_only')
|
|
return path, cfg, image, review, review_for
|
|
|
|
|
|
@pytest.mark.parametrize('version', ['2.0', '3.0'])
|
|
@pytest.mark.parametrize('svg', [False, True])
|
|
def test_reader_cover_identity_and_body_preservation(tmp_path, version, svg):
|
|
source, cfg, image, review, update = fixture(tmp_path, version, svg=svg)
|
|
ids = []
|
|
for mode in ['target_only','bilingual']:
|
|
update(mode); out = tmp_path/(mode+'.epub')
|
|
record = localize(source, out, cfg, mode, image, review); ids.append(record['identifier'])
|
|
_, root = read_package(out)
|
|
assert dc_values(root,'title') == [record['title']]
|
|
assert dc_values(root,'creator') == ['作者译名']
|
|
assert dc_values(root,'identifier') == [record['identifier']]
|
|
assert '9780000000001' in dc_values(root,'source')
|
|
assert dc_values(root,'language') == (['zh-CN'] if mode == 'target_only' else ['zh-CN','en'])
|
|
assert not root.xpath('//*[@refines="#title" or @refines="#author"]')
|
|
assert audit_edition(out)['issues'] == []
|
|
report, _, _ = audit_pair(source, out); assert report['new_issues'] == []
|
|
with zipfile.ZipFile(out) as z, zipfile.ZipFile(source) as original:
|
|
assert z.read('EPUB/chapter.xhtml') == original.read('EPUB/chapter.xhtml')
|
|
assert z.read('EPUB/images/old.png') == OLD
|
|
assert z.read('EPUB/images/body.png') == OLD
|
|
assert z.read(record['cover_path']) == PNG
|
|
assert record['cover_path'].endswith('.png')
|
|
doc=etree.fromstring(z.read('EPUB/cover.xhtml'))
|
|
ref = doc.xpath('//*[local-name()="image"]/@*[local-name()="href"]') if svg else doc.xpath('//*[local-name()="img"]/@src')
|
|
assert ref == [Path(record['cover_path']).name]
|
|
if svg: assert doc.xpath('//*[local-name()="svg"]/@preserveAspectRatio') == ['xMidYMid meet']
|
|
assert z.infolist()[0].filename == 'mimetype' and z.infolist()[0].compress_type == zipfile.ZIP_STORED
|
|
assert ids[0] != ids[1]
|
|
|
|
|
|
def test_absent_cover_adds_front_page_and_preserves_navigation(tmp_path):
|
|
source,cfg,image,review,_ = fixture(tmp_path,has_cover=False)
|
|
job = prepare(source,cfg,'target_only',tmp_path/'job'); assert job['reference'] is None
|
|
out=tmp_path/'new.epub'; localize(source,out,cfg,'target_only',image,review)
|
|
_,root=read_package(out)
|
|
assert root.xpath('//*[local-name()="spine"]/*[1]/@idref') == ['translation-cover-page']
|
|
assert audit_pair(source,out)[0]['new_issues'] == []
|
|
|
|
|
|
def test_fail_closed_for_wrong_book_unreviewed_cover_and_no_authority(tmp_path):
|
|
source,cfg,image,review,_ = fixture(tmp_path)
|
|
review.write_text('{}')
|
|
with pytest.raises(ValueError,match='Cover review'): localize(source,tmp_path/'out.epub',cfg,'target_only',image,review)
|
|
data=json.loads(cfg.read_text()); data['evidence']=[]; cfg.write_text(json.dumps(data))
|
|
with pytest.raises(ValueError,match='evidence'): prepare(source,cfg,'target_only',tmp_path/'job')
|
|
data['title_status']='project'; data['source']['titles']=['Wrong book']; cfg.write_text(json.dumps(data))
|
|
with pytest.raises(ValueError,match='does not match'): prepare(source,cfg,'target_only',tmp_path/'job')
|
|
|
|
|
|
def test_corrupt_cover_is_detected_by_headless_audit(tmp_path):
|
|
source,cfg,image,review,_=fixture(tmp_path); out=tmp_path/'out.epub'
|
|
record=localize(source,out,cfg,'target_only',image,review)
|
|
corrupted=tmp_path/'corrupt.epub'
|
|
with zipfile.ZipFile(out) as z, zipfile.ZipFile(corrupted,'w') as target:
|
|
for info in z.infolist(): target.writestr(info,b'broken' if info.filename==record['cover_path'] else z.read(info.filename))
|
|
assert 'edition_cover_hash_mismatch' in audit_edition(corrupted)['issues']
|
|
assert any(x['kind']=='edition_cover_hash_mismatch' for x in audit_pair(source,corrupted)[0]['new_issues'])
|
|
|
|
|
|
def test_pipeline_asset_paths_are_relative_to_config(tmp_path):
|
|
source,cfg,image,review,_=fixture(tmp_path)
|
|
data=json.loads(cfg.read_text()); data['covers']={'target_only':{'image':image.name,'review':review.name}}; cfg.write_text(json.dumps(data))
|
|
output=localize_from_config(source,cfg,'target_only'); assert output.exists()
|
|
with pytest.raises(ValueError,match='Already localized'): localize(output,tmp_path/'again.epub',cfg,'target_only',image,review)
|
|
with pytest.raises(ValueError,match='overwrite'): localize(source,source,cfg,'target_only',image,review)
|
|
|
|
|
|
def test_external_svg_cover_wrapper_is_cloned_not_overwritten(tmp_path):
|
|
source,cfg,image,review,_=fixture(tmp_path)
|
|
original=tmp_path/'wrapped.epub'
|
|
svg=b'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="old.png"/></svg>'
|
|
with zipfile.ZipFile(source) as z,zipfile.ZipFile(original,'w') as target:
|
|
for info in z.infolist():
|
|
data=z.read(info.filename)
|
|
if info.filename=='EPUB/package.opf':
|
|
data=data.replace(b'</manifest>',b'<item id="wrapper" href="images/wrapper.svg" media-type="image/svg+xml"/></manifest>')
|
|
if info.filename=='EPUB/cover.xhtml': data=data.replace(b'images/old.png',b'images/wrapper.svg')
|
|
target.writestr(info,data)
|
|
target.writestr('EPUB/images/wrapper.svg',svg)
|
|
out=tmp_path/'localized.epub'; record=localize(original,out,cfg,'target_only',image,review)
|
|
with zipfile.ZipFile(out) as z:
|
|
assert z.read('EPUB/images/wrapper.svg')==svg
|
|
assert b'translation-wrapper-' in z.read('EPUB/cover.xhtml')
|
|
assert len(record['cover_bindings'])==2
|
|
assert audit_pair(original,out)[0]['new_issues']==[]
|
|
|
|
|
|
def test_headless_can_render_a_new_cover_without_a_source_counterpart(tmp_path):
|
|
import os
|
|
if not os.environ.get('PLAYWRIGHT_BROWSERS_PATH'):
|
|
pytest.skip('Optional browser integration; set PLAYWRIGHT_BROWSERS_PATH')
|
|
pytest.importorskip('playwright.sync_api')
|
|
from src.qa.headless import book_inventory, render_pair
|
|
source,cfg,image,review,_ = fixture(tmp_path,has_cover=False)
|
|
out=tmp_path/'edition.epub'; record=localize(source,out,cfg,'target_only',image,review)
|
|
cover_page=record['cover_bindings'][0]['file']
|
|
evidence=tmp_path/'evidence';evidence.mkdir()
|
|
result=render_pair(source,out,book_inventory(source),book_inventory(out),evidence,[cover_page],1,1)
|
|
assert result['sampled_documents']==[cover_page]
|
|
assert len(result['images'])==1 and result['images'][0]['book']=='translated'
|
|
assert (evidence/result['images'][0]['image']).exists()
|
|
|
|
|
|
def test_new_edition_uuid_keeps_obfuscated_font_readable_and_audited(tmp_path):
|
|
from src.edition.fonts import decoded_fonts
|
|
import uuid
|
|
source,cfg,image,review,_ = fixture(tmp_path)
|
|
old_id = 'urn:uuid:' + str(uuid.uuid4())
|
|
plain = b'\x00\x01\x00\x00' + bytes(range(256)) * 8
|
|
key = uuid.UUID(old_id.removeprefix('urn:uuid:')).bytes
|
|
encoded = bytes(byte ^ key[i % 16] if i < 1024 else byte for i,byte in enumerate(plain))
|
|
font_source = tmp_path/'fonts.epub'
|
|
with zipfile.ZipFile(source) as z, zipfile.ZipFile(font_source,'w') as target:
|
|
for info in z.infolist():
|
|
data = z.read(info.filename)
|
|
if info.filename == 'EPUB/package.opf':
|
|
data = data.replace(b'id="old-id">9780000000001', b'id="old-id">'+old_id.encode())
|
|
data = data.replace(b'</manifest>', b'<item id="font" href="fonts/face.ttf" media-type="font/ttf"/><item id="css" href="style.css" media-type="text/css"/></manifest>')
|
|
target.writestr(info,data)
|
|
target.writestr('EPUB/fonts/face.ttf',encoded)
|
|
target.writestr('EPUB/style.css', '@font-face {font-family: Face; src:url("fonts/face.ttf")}')
|
|
target.writestr('META-INF/encryption.xml', '<encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptionMethod Algorithm="http://ns.adobe.com/pdf/enc#RC"/><CipherData><CipherReference URI="EPUB/fonts/face.ttf"/></CipherData></EncryptedData></encryption>')
|
|
config=json.loads(cfg.read_text());config['source']['identifier']=old_id;cfg.write_text(json.dumps(config))
|
|
out=tmp_path/'new-fonts.epub';record=localize(font_source,out,cfg,'target_only',image,review)
|
|
assert record['identifier'] != old_id
|
|
assert audit_edition(out)['issues'] == []
|
|
assert audit_pair(font_source,out)[0]['new_issues'] == []
|
|
with zipfile.ZipFile(out) as z:
|
|
opf,root=read_package(out)
|
|
assert list(decoded_fonts(z,opf,root).values()) == [plain]
|
|
assert z.read('EPUB/fonts/face.ttf') == encoded
|
|
from src.qa.headless import archive_server
|
|
from urllib.request import urlopen
|
|
with archive_server({'edition': out}) as url:
|
|
with urlopen(url + '/edition/' + record['font_bindings'][0]['active_path']) as response:
|
|
assert response.read() == plain
|
|
damaged=tmp_path/'damaged-fonts.epub'
|
|
active=record['font_bindings'][0]['active_path']
|
|
with zipfile.ZipFile(out) as z,zipfile.ZipFile(damaged,'w') as target:
|
|
for info in z.infolist(): target.writestr(info,b'broken' if info.filename==active else z.read(info.filename))
|
|
assert any('font_binding' in issue for issue in audit_edition(damaged)['issues'])
|
|
|
|
|
|
def test_metadata_comments_survive_localization(tmp_path):
|
|
source,cfg,image,review,_ = fixture(tmp_path)
|
|
commented=tmp_path/'commented.epub'
|
|
with zipfile.ZipFile(source) as z,zipfile.ZipFile(commented,'w') as target:
|
|
for info in z.infolist():
|
|
raw=z.read(info.filename)
|
|
if info.filename=='EPUB/package.opf':
|
|
raw=raw.replace(b'<metadata>',b'<metadata><!-- Publisher metadata comment -->')
|
|
target.writestr(info,raw)
|
|
out=tmp_path/'localized.epub'
|
|
localize(commented,out,cfg,'target_only',image,review)
|
|
assert audit_edition(out)['issues']==[]
|
|
with zipfile.ZipFile(out) as z:
|
|
assert b'<!-- Publisher metadata comment -->' in z.read('EPUB/package.opf')
|