Fix edition font UUID rebinding and Protocols translation layout
This commit is contained in:
@@ -142,3 +142,43 @@ def test_headless_can_render_a_new_cover_without_a_source_counterpart(tmp_path):
|
||||
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'])
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from lxml import etree
|
||||
|
||||
from src.edition.fonts import ADOBE, IDPF, _key, decoded_fonts, primary_identifier, rebind_fonts
|
||||
|
||||
OPF = 'http://www.idpf.org/2007/opf'
|
||||
|
||||
|
||||
def key(identifier, algorithm):
|
||||
if algorithm == IDPF:
|
||||
return hashlib.sha1(''.join(identifier.split()).encode()).digest()
|
||||
return uuid.UUID(identifier.removeprefix('urn:uuid:')).bytes
|
||||
|
||||
|
||||
def xor(data, identifier, algorithm):
|
||||
k = key(identifier, algorithm); n = 1040 if algorithm == IDPF else 1024
|
||||
return bytes(b ^ k[i % len(k)] for i, b in enumerate(data[:n])) + data[n:]
|
||||
|
||||
|
||||
def fixture(tmp_path, algorithm):
|
||||
old = 'urn:uuid:' + str(uuid.UUID('12345678-1234-5678-1234-567812345678'))
|
||||
new = 'urn:uuid:' + str(uuid.UUID('87654321-4321-8765-4321-876543218765'))
|
||||
plain = b'\x00\x01\x00\x00' + bytes(range(256)) * 20
|
||||
encrypted = xor(plain, old, algorithm)
|
||||
opf = f'''<package xmlns="{OPF}" unique-identifier="uid"><metadata><identifier id="uid">{old}</identifier></metadata><manifest><item id="font" href="fonts/a.ttf" media-type="application/x-font-truetype"/><item id="css" href="styles/main.css" media-type="text/css"/><item id="page" href="text/ch.xhtml" media-type="application/xhtml+xml"/></manifest></package>'''
|
||||
enc = f'''<encryption xmlns="http://www.w3.org/2001/04/xmlenc#"><EncryptedData><EncryptionMethod Algorithm="{algorithm}"/><CipherData><CipherReference URI="EPUB/fonts/a.ttf"/></CipherData></EncryptedData></encryption>'''
|
||||
path = tmp_path / 'book.epub'
|
||||
with zipfile.ZipFile(path, 'w') as z:
|
||||
z.writestr('EPUB/package.opf', opf)
|
||||
z.writestr('META-INF/encryption.xml', enc)
|
||||
z.writestr('EPUB/fonts/a.ttf', encrypted)
|
||||
z.writestr('EPUB/styles/main.css', b'@font-face {src:url("./../fonts/%61.ttf")} /* url(https://example.org/fonts/a.ttf) */')
|
||||
z.writestr('EPUB/text/ch.xhtml', '<html xmlns="http://www.w3.org/1999/xhtml"><head><link rel="preload" as="font" href="../fonts/./a.ttf"/><style>@font-face{src:url(../fonts/a.ttf)}</style></head><body><p style="font-family:url(../fonts/a.ttf)">x</p></body></html>')
|
||||
root = etree.fromstring(opf.encode())
|
||||
return path, root, old, new, plain, encrypted
|
||||
|
||||
|
||||
@pytest.mark.parametrize('algorithm', [IDPF, ADOBE])
|
||||
def test_decode_and_rebind_preserves_original_and_rekeys_css_urls(tmp_path, algorithm):
|
||||
path, root, old, new, plain, encrypted = fixture(tmp_path, algorithm)
|
||||
with zipfile.ZipFile(path) as z:
|
||||
assert primary_identifier(root) == old
|
||||
assert decoded_fonts(z, 'EPUB/package.opf', root) == {'EPUB/fonts/a.ttf': plain}
|
||||
additions, replacements = {}, {}
|
||||
records = rebind_fonts(z, 'EPUB/package.opf', root, old, new, additions, replacements)
|
||||
assert len(records) == 1 and records[0]['algorithm'] == algorithm
|
||||
assert records[0]['original_sha256'] == hashlib.sha256(encrypted).hexdigest()
|
||||
assert records[0]['decoded_sha256'] == hashlib.sha256(plain).hexdigest()
|
||||
assert additions['EPUB/fonts/edition-a.ttf'] == xor(plain, new, algorithm)
|
||||
assert z.read('EPUB/fonts/a.ttf') == encrypted
|
||||
assert root.xpath('//*[local-name()="item" and @id="font"]/@href') == ['fonts/edition-a.ttf']
|
||||
assert root.xpath('//*[local-name()="item" and @id="font-source"]/@media-type') == ['application/octet-stream']
|
||||
assert b'../fonts/edition-a.ttf' in replacements['EPUB/styles/main.css']
|
||||
assert b'../fonts/edition-a.ttf' in replacements['EPUB/text/ch.xhtml']
|
||||
assert b'https://example.org/fonts/a.ttf' in replacements['EPUB/styles/main.css']
|
||||
assert b'edition-a.ttf' in replacements['EPUB/text/ch.xhtml']
|
||||
enc = etree.fromstring(replacements['META-INF/encryption.xml'])
|
||||
assert enc.xpath('//*[local-name()="CipherReference"]/@URI') == ['EPUB/fonts/edition-a.ttf']
|
||||
# The replacement payload decodes under the new primary identifier.
|
||||
root.xpath('//*[local-name()="identifier" and @id="uid"]')[0].text = new
|
||||
replacements['EPUB/package.opf'] = etree.tostring(root)
|
||||
class ZipView:
|
||||
def read(self, name):
|
||||
if name in replacements: return replacements[name]
|
||||
return additions[name] if name in additions else z.read(name)
|
||||
def namelist(self): return z.namelist() + list(additions)
|
||||
view = ZipView()
|
||||
decoded = decoded_fonts(view, 'EPUB/package.opf', root)
|
||||
assert decoded['EPUB/fonts/edition-a.ttf'] == plain
|
||||
|
||||
|
||||
def test_rejects_unknown_algorithm_and_non_font_payload(tmp_path):
|
||||
path, root, old, new, plain, encrypted = fixture(tmp_path, 'urn:unknown')
|
||||
with zipfile.ZipFile(path) as z, pytest.raises(ValueError, match='Unsupported EPUB encryption algorithm'):
|
||||
decoded_fonts(z, 'EPUB/package.opf', root)
|
||||
path, root, old, new, plain, encrypted = fixture(tmp_path, IDPF)
|
||||
with zipfile.ZipFile(path, 'w') as z:
|
||||
z.writestr('EPUB/package.opf', f'''<package xmlns="{OPF}" unique-identifier="uid"><metadata><identifier id="uid">{old}</identifier></metadata><manifest><item id="font" href="fonts/a.ttf" media-type="font/ttf"/></manifest></package>''')
|
||||
z.writestr('META-INF/encryption.xml', f'''<encryption><EncryptedData><EncryptionMethod Algorithm="{IDPF}"/><CipherData><CipherReference URI="EPUB/fonts/a.ttf"/></CipherData></EncryptedData></encryption>''')
|
||||
z.writestr('EPUB/fonts/a.ttf', xor(b'not-a-font'+bytes(1200), old, IDPF))
|
||||
root = etree.fromstring(f'''<package xmlns="{OPF}" unique-identifier="uid"><metadata><identifier id="uid">{old}</identifier></metadata><manifest><item id="font" href="fonts/a.ttf" media-type="font/ttf"/></manifest></package>'''.encode())
|
||||
with zipfile.ZipFile(path) as z, pytest.raises(ValueError, match='recognized font signature'):
|
||||
decoded_fonts(z, 'EPUB/package.opf', root)
|
||||
|
||||
|
||||
def test_rejects_unsafe_cipher_path(tmp_path):
|
||||
path, root, old, new, plain, encrypted = fixture(tmp_path, IDPF)
|
||||
with zipfile.ZipFile(path, 'w') as z:
|
||||
z.writestr('EPUB/package.opf', f'''<package xmlns="{OPF}" unique-identifier="uid"><metadata><identifier id="uid">{old}</identifier></metadata><manifest><item id="font" href="fonts/a.ttf" media-type="font/ttf"/></manifest></package>''')
|
||||
z.writestr('META-INF/encryption.xml', f'''<encryption><EncryptedData><EncryptionMethod Algorithm="{IDPF}"/><CipherData><CipherReference URI="../../outside.ttf"/></CipherData></EncryptedData></encryption>''')
|
||||
root = etree.fromstring(f'''<package xmlns="{OPF}" unique-identifier="uid"><metadata><identifier id="uid">{old}</identifier></metadata><manifest><item id="font" href="fonts/a.ttf" media-type="font/ttf"/></manifest></package>'''.encode())
|
||||
with zipfile.ZipFile(path) as z, pytest.raises(ValueError, match='escapes EPUB root'):
|
||||
decoded_fonts(z, 'EPUB/package.opf', root)
|
||||
|
||||
|
||||
def test_idpf_whitespace_removal_is_limited_to_xml_whitespace():
|
||||
assert _key('ab c\td\re\nf', IDPF) == hashlib.sha1(b'abcdef').digest()
|
||||
# Unicode whitespace is not part of XML's four whitespace code points.
|
||||
assert _key('ab\u00a0c', IDPF) == hashlib.sha1('ab\u00a0c'.encode()).digest()
|
||||
@@ -315,3 +315,15 @@ def test_nested_toc_anchor_binds_heading_and_normalizes_visible_ordinal():
|
||||
# A nested anchor in ordinary prose must still not become a title.
|
||||
assert not _headings(parse_html('<p><a id="prose">A sentence.</a></p>'),
|
||||
'chapter.xhtml#prose', 'Chapter 1. Obsession')[0]
|
||||
|
||||
|
||||
def test_graphical_title_binds_using_alt_without_mutating_image():
|
||||
from src.common.html import parse_html
|
||||
from src.preprocessing.navigation import _headings
|
||||
soup = parse_html('<section id="ch"><h1 id="heading">CHAPTER 1'
|
||||
'<span><img src="word.jpeg" alt="Protocols."/></span>'
|
||||
'<span><i>for</i> SLEEP</span></h1></section>')
|
||||
before = str(soup)
|
||||
headings, reason = _headings(soup, 'ch.xhtml#ch', 'Chapter 1: Protocols for Sleep')
|
||||
assert reason is None and [h['id'] for h in headings] == ['heading']
|
||||
assert str(soup) == before
|
||||
|
||||
@@ -381,3 +381,11 @@ def test_quota_failure_preserves_completed_translation_and_checkpoint():
|
||||
assert entries[0].translated_text == '已完成。'
|
||||
assert not any(e.translated_text for e in entries[1:])
|
||||
assert snapshots and snapshots[-1][0] == '已完成。'
|
||||
|
||||
|
||||
def test_styled_protocol_number_cannot_be_deleted():
|
||||
e = entry('<h1>Protocol <span><b>3</b></span> Title</h1>')
|
||||
missing = e.original_text.replace('3φ/', 'φ/')
|
||||
assert FormatRestorer().restore(missing, e.placeholders, original_text=e.original_text) == ('', False)
|
||||
localized = e.original_text.replace('3φ/', '三φ/')
|
||||
assert FormatRestorer().restore(localized, e.placeholders, original_text=e.original_text)[1]
|
||||
|
||||
Reference in New Issue
Block a user