Fix edition font UUID rebinding and Protocols translation layout

This commit is contained in:
Kai Tan
2026-10-10 00:42:37 +08:00
parent 26da365cb2
commit 3e2093dba8
12 changed files with 522 additions and 4 deletions
+40
View File
@@ -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'])
+103
View File
@@ -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()
+12
View File
@@ -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
+8
View File
@@ -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]