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
+9
View File
@@ -1,3 +1,12 @@
## 2026-10-10 — Protocols AZW3 edition and embedded fonts
- Bind graphical chapter headings to navigation using image alt text while preserving source markup.
- Preserve publisher heading and note spacing; apply translation separation to prose and bilingual insertions.
- Rebind standard IDPF/Adobe font obfuscation when creating independent edition UUIDs, preserving original font bytes and auditing decoded content.
- Serve decoded embedded fonts in headless QA; cover legacy Calibre font MIME types and local CSS/style/preload references.
- Reject empty translated spans that delete styled protocol numbers.
- Complete Protocols in both EPUB modes with Chinese covers, source-bound metadata, resource preservation and documented sample QA.
## 2026-10-08 — nested chapter anchors and Highest Exam regression
- Bind TOC anchors nested inside headings to their enclosing title; exclude punctuation-only ornaments from heading groups.
+57
View File
@@ -0,0 +1,57 @@
# Protocols — AZW3 转 EPUB、Luna 翻译与抽样 QA
本次按更新后的 `skills/epub-translate/SKILL.md` 完成纯中文与中英对照两版,版本 `luna-r1`。项目译名为《实践方案:人体操作手册》;没有确认正式中文出版书名。正文由 `gpt-6-luna` 子 agent 按完整 Manifest 执行,无外部翻译 API。封面使用 Codex 内置 imagegen 编辑原封面,工具未公开精确模型 ID,记录为 `model: null`。
## 输入与范围
- 原始 AZW3 SHA256:`70c07109484301970f8ba9df7a503b11a56d768102883a2a6f02fc68c7daba27`。
- Calibre 转换后冻结 EPUB SHA256:`5c0f743c8afa9c0a24044c3dfde267d05c0141674aae3c7b4398d7a05be9fefa`。
- AZW3 解包与 EPUB 的正文空白词元计数均为 228,905,计数差为零。这是转换完整度证据,不能替代结构或逐句语义审校。
- 完整提取 5,009 单元,69 个大段上下文批次;术语表 94 条。范围包含前言、声明、全部正文、致谢、作者介绍、参考文献、索引和版权页。
- 原书 84 个 XHTML 文档、64 个目录项。书内注释与印刷页码/索引链接分别审计,不能把全部内部链接称为脚注。
## 此次修复
1. 章节标题中使用图片表示 `Protocols`,标题匹配原本忽略图片 alt。现以副本中的 alt 参与目录绑定,原图片和 DOM 保留;实际章节标题译文成为目录共享来源,减少14个重复提取的目录单元。
2. 通用翻译 margin 覆盖出版方章节标题顶部留白。现仅对正文和双语插入单元增加间距,保留中文替换标题及注释的出版方间距。
3. 换 AI 版本 UUID 会破坏 EPUB 标准字体混淆。新增 IDPF/Adobe 字体处理:保留原字体字节,克隆并重新绑定活跃字体,改写 manifest、encryption.xml 和本地 CSS/inline/font preload 引用。7个字体的解码结果独立与 AZW3 原始字体逐字节核对;headless 在内存中解码字体再提供给浏览器。
4. 转换源有两个空 Calibre 页分隔 div 与章节标题重 ID;重建后的最终输出不再出现这些重复 ID,原章节标题锚点和链接目标仍有效。
5. 视觉抽查及全书开头强调标记位置筛查修正两处短提示粗体移位、两处学习时间段的粗体范围、一处脚注上标延伸到解释句;修正翻译结果中的标记位置,再完整回填。长度比例筛查未发现其他长范围错移,但它不能替代语义检查。
6. 最终英文残留检查修正索引标题和57个遗漏索引词、6个注释章名;品牌、URL、文献标题及刊名按原作保留。
7. 图形编号抽查发现个人成长方案3/4的嵌套标记内数字漏掉,恢复原数字;加入校验,禁止数字标记范围被翻译为空,允许非空的本地化数字。
## 版本整理与验收边界
两版使用不同颜色徽标、独立 UUID、模式及修订号一致的 metadata;作者保留原拼写 Andrew D. Huberman,封面保留 with Jessica Wapner 的原作贡献者署名。原作 ISBN/UUID 记录为 source,不用作 AI 译本出版身份。中文译本日期为生成日期,原出版社/日期留在版本来源记录。
原 AZW3 的15个图片/字体资产全部按 SHA 保留在最终 EPUB;转换 EPUB 的14个原二进制资源原路径原字节不变。额外保留转换时未使用的小封面缩略图和原始解码字体,使用新封面、新字体克隆作为活跃资源。
独立 Luna 语义抽查23个长段(15个运动、营养、压力、光照、学习样本;8个睡眠与引言样本),并由主 agent 核对补充剂剂量、警告、否定与不确定性、列表、标题和书后文字。不是全书逐句人工校对。
最终 headless 对照抽样包含封面、引言、图形章节标题、睡眠补充剂列表、饮酒段落、压力管理、WOOP、表达性写作、参考注释和索引;另查看180×270书库缩略图。浏览器禁用书内脚本和外部请求。原始自动报告中的 visual/semantic 状态保留 pending,实际人工/agent 审核证据另写 review-record.json。Chromium 连续 XHTML 的截图不代表所有阅读器的分页。
原图形章节词 `Protocols`、整页英文图片扉页、商标图片内文字保留英文;它们不属于文本翻译提取范围。参考文献部分保留可追溯的原文文献标题/刊名。索引保持原作顺序和印刷页码链接,未重排为中文拼音索引。
## 本地证据
工作目录:`.work/luna-protocols/`(不提交书籍或译文到仓库)。
- `conversion-source.json`、`conversion-review.json`:AZW3 转换与来源。
- `manifest.json`、`book_structure.json`、`agent-run.json`、`results/`:全书范围、源摘要与翻译结果。
- `semantic-review-luna.json`、`semantic-review-sleep-luna.json`:独立语义抽查。
- `edition/edition.json`、各模式 `review.json`:书名来源、封面提供方及图片 SHA。
- `asset-font-verification.json`:最终资源 SHA 与字体解码验证。
- `qa-中文版/`、`qa-中英对照版/`:最终结构报告与对照截图。
- `qa-thumb-final-中文版/`、`qa-thumb-final-中英对照版/`:实际 EPUB 缩略图。
- `review-record.json`:最终输出 SHA、实际已看截图和抽样范围。
书目核对依据:[原作出版社页面](https://www.simonandschuster.com/books/Protocols/Andrew-D-Huberman/9781668032152)、[作者关于翻译版本的 FAQ](https://www.hubermanlab.com/faq/will-protocols-be-available-in-languages-other-than-english)。前者用于原作核对,后者不构成具体中文书名的权威依据。字体处理依据:[W3C EPUB 3.3 字体混淆规范](https://www.w3.org/TR/epub-33/#sec-font-obfuscation)、[Calibre 字体处理实现](https://github.com/kovidgoyal/calibre/blob/master/src/calibre/ebooks/oeb/polish/container.py)。
## 最终验证结果
两版均为5,009/5,009完整回填、64个目录项、零新增结构问题、零新增自动布局问题。原书406个书内注释引用保留;双语版同时保留原文和译文引用,所以为812个。每版抽样18次内部目标点击(原文9次、译文9次),其中原文/译文各4次分类为书内注释且返回链接成功;其余5次各为非注释目标,不能混称脚注。
每版生成13个文档、48张截图,最终记录明确列出实际已查看图片与SHA;另有前几轮原文/译文对照证据。独立语义抽查23个长段,原自动报告没有改写成全书视觉/语义通过。测试结果:89 passed,2 skipped;实际 Chromium EPUB 复验另行完成。
- Protocols_中文版_luna-r1.epub:`86ce3cf735443bc33d6e92093d275e61d6b568b15ebe8228949a46f9203bfef2`。
- Protocols_中英对照版_luna-r1.epub:`7995e864d5044a7637d8a5f14a9e782936f7df22bdb7b0bfbd5e78f0a34a12e0`。
+5
View File
@@ -2,6 +2,11 @@
.translation {
line-height: 1.6;
clear: both;
}
/* Preserve publisher spacing on replacement headings and notes. Only prose
and a bilingual insertion need the additional translation separation. */
.translation[data-translation-role="body"],
.translation[data-translation-for] {
margin-top: 0.5em;
}
/* Units wrapped around loose text must remain visible before nested blocks. */
+7
View File
@@ -32,6 +32,13 @@ class FormatRestorer:
stack.append(pid)
if stack:
issues.append('unclosed placeholder')
if original_text is not None:
# Token counts alone cannot detect deletion of a styled chapter or
# protocol number. Accept localized numerals, but never an empty span.
for match in re.finditer(r'φ(\d+)φ([0-9]+)φ/\1φ', original_text):
target = re.search(r'φ' + match[1] + r'φ(.*?)φ/' + match[1] + r'φ', text, re.S)
if target and not self.PLACEHOLDER_REGEX.sub('', target[1]).strip():
issues.append('empty numeric placeholder span: ' + match[1])
return issues
def restore(self, text_with_placeholders, placeholder_map, context_id=None, original_text=None):
+246
View File
@@ -0,0 +1,246 @@
"""Safe decoding and identifier rebinding for standard EPUB font obfuscation."""
from __future__ import annotations
import hashlib
import posixpath
import re
import uuid
import zipfile
from urllib.parse import unquote, urlsplit, quote
from lxml import etree
OPF = 'http://www.idpf.org/2007/opf'
DC = 'http://purl.org/dc/elements/1.1/'
IDPF = 'http://www.idpf.org/2008/embedding'
ADOBE = 'http://ns.adobe.com/pdf/enc#RC'
ENCRYPTION = 'http://www.w3.org/2001/04/xmlenc#'
PARSER = lambda: etree.XMLParser(resolve_entities=False, no_network=True)
_MAGICS = (b'\x00\x01\x00\x00', b'OTTO', b'true', b'typ1', b'ttcf', b'wOFF', b'wOF2')
_FONT_MEDIA_TYPES = {
'application/font-sfnt', 'application/vnd.ms-opentype',
'application/x-font-truetype', 'application/x-font-ttf',
'application/x-font-opentype', 'application/x-font-otf',
'application/x-font-woff', 'application/font-woff',
'application/font-woff2', 'font-woff', 'font-woff2',
}
def primary_identifier(root):
"""Return the package identifier designated by unique-identifier."""
uid = root.get('unique-identifier')
matches = root.xpath('//*[local-name()="identifier" and @id=$uid]', uid=uid)
if not uid or len(matches) != 1 or not (matches[0].text or '').strip():
raise ValueError('Missing or ambiguous OPF primary identifier')
return matches[0].text
def _key(identifier, algorithm):
if algorithm == IDPF:
return hashlib.sha1(re.sub(r'[ \t\r\n]+', '', identifier).encode('utf-8')).digest()
if algorithm == ADOBE:
value = identifier.strip()
if value.lower().startswith('urn:uuid:'):
value = value[9:]
try:
return uuid.UUID(value.strip('{}')).bytes
except (ValueError, AttributeError) as exc:
raise ValueError('Adobe font obfuscation requires a UUID primary identifier') from exc
raise ValueError(f'Unsupported EPUB font obfuscation algorithm: {algorithm}')
def _safe_archive_path(base, uri):
parts = urlsplit(uri)
if parts.scheme or parts.netloc or parts.query or parts.fragment or not parts.path:
raise ValueError(f'Unsafe encrypted font URI: {uri}')
raw = unquote(parts.path)
if raw.startswith('/') or '\\' in raw or '\x00' in raw:
raise ValueError(f'Unsafe encrypted font path: {uri}')
path = posixpath.normpath(posixpath.join(posixpath.dirname(base), raw))
if path in {'.', '..'} or path.startswith('../'):
raise ValueError(f'Encrypted font escapes EPUB root: {uri}')
return path
def _obfuscated(z, opf_path):
try:
root = etree.fromstring(z.read('META-INF/encryption.xml'), PARSER())
except KeyError:
return []
found = []
for node in root.xpath('//*[local-name()="EncryptedData"]'):
methods = node.xpath('.//*[local-name()="EncryptionMethod"]/@Algorithm')
refs = node.xpath('.//*[local-name()="CipherReference"]/@URI')
if not methods and not refs:
continue
if len(methods) != 1 or len(refs) != 1:
raise ValueError('Malformed EPUB encryption.xml font entry')
algorithm = methods[0]
if algorithm not in {IDPF, ADOBE}:
raise ValueError(f'Unsupported EPUB encryption algorithm: {algorithm}')
# CipherReference URIs are rooted at the OCF container root.
path = _safe_archive_path('encryption.xml', refs[0])
if path in {x[0] for x in found}:
raise ValueError(f'Duplicate encrypted resource: {path}')
# The OPF must declare encrypted resources as fonts, not arbitrary payloads.
package = etree.fromstring(z.read(opf_path), PARSER())
items = package.xpath('//*[local-name()="manifest"]/*[local-name()="item"]')
item = next((it for it in items if _safe_archive_path(opf_path, it.get('href', '')) == path), None)
media_type = item.get('media-type', '').lower() if item is not None else ''
if item is None or not (media_type.startswith('font/') or media_type in _FONT_MEDIA_TYPES):
raise ValueError(f'Encrypted resource is not a manifest font: {path}')
found.append((path, algorithm))
return found
def _xor_font(data, key, algorithm):
length = 1040 if algorithm == IDPF else 1024
n = min(length, len(data))
return bytes(data[i] ^ key[i % len(key)] for i in range(n)) + data[n:]
def _font_magic(data):
return data.startswith(_MAGICS)
def decoded_fonts(z, opf_path, root):
"""Return decoded bytes for all standard IDPF/Adobe-obfuscated manifest fonts."""
identifier = primary_identifier(root)
result = {}
for path, algorithm in _obfuscated(z, opf_path):
try:
data = z.read(path)
except KeyError as exc:
raise ValueError(f'Encrypted font is missing from EPUB: {path}') from exc
decoded = _xor_font(data, _key(identifier, algorithm), algorithm)
if not _font_magic(decoded):
raise ValueError(f'Decoded resource does not have a recognized font signature: {path}')
result[path] = decoded
return result
def _href_for_path(base, target):
return quote(posixpath.relpath(target, posixpath.dirname(base)), safe='/._-')
def _mapped_ref(value, document_path, path_map):
"""Map local resource references only; external/data URLs stay untouched."""
parts = urlsplit(value.strip())
if parts.scheme or parts.netloc or parts.query or parts.fragment or not parts.path:
return None
try:
target = _safe_archive_path(document_path, parts.path)
except ValueError:
return None
replacement = path_map.get(target)
return _href_for_path(document_path, replacement) if replacement else None
def _rewrite_css_urls(text, document_path, path_map):
pattern = re.compile(r'(?P<prefix>url\(\s*)(?P<quote>[\"\']?)(?P<url>[^\"\')]*?)(?P=quote)(?P<suffix>\s*\))', re.I)
def substitute(match):
new_url = _mapped_ref(match.group('url'), document_path, path_map)
if new_url is None:
return match.group(0)
return match.group('prefix') + match.group('quote') + new_url + match.group('quote') + match.group('suffix')
return pattern.sub(substitute, text)
def rebind_fonts(z, opf_path, root, old_identifier, new_identifier, additions, replacements):
"""Clone decoded fonts under edition-* paths, preserving the source font bytes."""
primary = primary_identifier(root)
if primary not in {old_identifier, new_identifier}:
raise ValueError('Provided identifiers do not match OPF primary identifier')
# localize() may already have installed the new identifier on the in-memory
# OPF; decoding must still use the source identifier that protected the bytes.
if primary != old_identifier:
uid = root.get('unique-identifier')
node = root.xpath('//*[local-name()="identifier" and @id=$uid]', uid=uid)[0]
node.text = old_identifier
try:
fonts = decoded_fonts(z, opf_path, root)
finally:
node.text = new_identifier
else:
fonts = decoded_fonts(z, opf_path, root)
if not fonts:
return []
manifest = root.find(f'{{{OPF}}}manifest')
if manifest is None:
raise ValueError('OPF manifest missing while rebinding fonts')
ids = {node.get('id') for node in manifest}
records = []
path_map = {}
for original_path, decoded in fonts.items():
algorithm = next(a for p, a in _obfuscated(z, opf_path) if p == original_path)
item = next((it for it in manifest if _safe_archive_path(opf_path, it.get('href', '')) == original_path), None)
if item is None:
raise ValueError(f'Encrypted font is missing from OPF manifest: {original_path}')
basename = posixpath.basename(original_path)
active_path = posixpath.join(posixpath.dirname(original_path), 'edition-' + basename)
if active_path in z.namelist() or active_path in additions:
raise ValueError(f'Edition font clone path already exists: {active_path}')
active_data = _xor_font(decoded, _key(new_identifier, algorithm), algorithm)
if not _font_magic(_xor_font(active_data, _key(new_identifier, algorithm), algorithm)):
raise ValueError(f'Rebound font failed signature check: {active_path}')
old_href = item.get('href')
new_href = _href_for_path(opf_path, active_path)
path_map[original_path] = active_path
item.set('href', new_href)
old_id = item.get('id') or 'font'
preserved_id = old_id + '-source'
while preserved_id in ids:
preserved_id += '-new'
ids.add(preserved_id)
source_href = _href_for_path(opf_path, original_path)
etree.SubElement(manifest, f'{{{OPF}}}item', id=preserved_id, href=source_href, **{'media-type': 'application/octet-stream'})
additions[active_path] = active_data
records.append({'original_path': original_path, 'active_path': active_path,
'original_sha256': hashlib.sha256(z.read(original_path)).hexdigest(),
'decoded_sha256': hashlib.sha256(decoded).hexdigest(), 'algorithm': algorithm})
# Retarget encryption.xml to cloned active resources. It is written via replacements.
enc_path = 'META-INF/encryption.xml'
enc = etree.fromstring(replacements.get(enc_path, z.read(enc_path)), PARSER())
for node in enc.xpath('//*[local-name()="CipherReference"]'):
uri = node.get('URI', '')
target = _safe_archive_path('encryption.xml', uri)
if target in path_map:
node.set('URI', path_map[target])
replacements[enc_path] = etree.tostring(enc, encoding='utf-8', xml_declaration=True)
# Retarget CSS url(...) and inline XHTML style declarations using pending replacements.
for name in z.namelist():
lower = name.lower()
if lower.endswith('.css'):
raw = replacements.get(name, z.read(name))
text = raw.decode('utf-8')
text = _rewrite_css_urls(text, name, path_map)
replacements[name] = text.encode('utf-8')
elif lower.endswith(('.xhtml', '.html', '.htm')):
raw = replacements.get(name, z.read(name))
try:
doc = etree.fromstring(raw, PARSER())
except etree.XMLSyntaxError:
continue
changed = False
for node in doc.iter():
if not isinstance(node.tag, str):
continue
style = node.get('style')
if style:
updated = _rewrite_css_urls(style, name, path_map)
if updated != style:
node.set('style', updated); changed = True
if etree.QName(node).localname.lower() == 'link' and 'preload' in node.get('rel', '').lower().split() and node.get('as', '').lower() == 'font':
href = node.get('href')
updated = _mapped_ref(href, name, path_map) if href else None
if updated:
node.set('href', updated); changed = True
if etree.QName(node).localname.lower() == 'style' and node.text:
updated = _rewrite_css_urls(node.text, name, path_map)
if updated != node.text:
node.text = updated; changed = True
if changed:
replacements[name] = etree.tostring(doc, encoding='utf-8', xml_declaration=True)
return records
+12
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from urllib.parse import unquote, urlsplit, quote
from lxml import etree
from src.assembly.package_writer import package_path, OPF, DC
from src.edition.fonts import primary_identifier, rebind_fonts, decoded_fonts
LABELS = {'target_only': '中文版', 'bilingual': '中英对照版'}
PREFIX = 'https://kais.cloud/epub-translator/metadata/'
@@ -144,6 +145,7 @@ def localize(epub, output, config, mode, cover, review):
verify_binding(root, cfg)
image, mime, suffix, reviewed = reviewed_cover(cover, review, cfg, mode)
metadata = root.find(f'{{{OPF}}}metadata'); manifest = root.find(f'{{{OPF}}}manifest')
old_identifier = primary_identifier(root)
original = {'titles': dc_values(root, 'title'), 'authors': dc_values(root, 'creator'),
'identifiers': dc_values(root, 'identifier'), 'languages': dc_values(root, 'language'),
'publishers': dc_values(root, 'publisher'), 'dates': dc_values(root, 'date')}
@@ -276,6 +278,7 @@ def localize(epub, output, config, mode, cover, review):
record['cover_bindings'].append({'file':page_path, 'target':image_path})
etree.SubElement(manifest, f'{{{OPF}}}item', id=page_id, href=page_href, **{'media-type': 'application/xhtml+xml'})
root.find(f'{{{OPF}}}spine').insert(0, etree.Element(f'{{{OPF}}}itemref', idref=page_id, linear='yes'))
record['font_bindings'] = rebind_fonts(z, opf_path, root, old_identifier, identifier, additions, replacements)
if root.get('version', '').startswith('3'):
prefix = root.get('prefix', '')
if 'translation:' in prefix: raise ValueError('Source already defines translation prefix')
@@ -315,6 +318,15 @@ def audit_edition(epub):
with zipfile.ZipFile(epub) as z:
if record['cover_path'] not in z.namelist() or sha(z.read(record['cover_path'])) != record['cover_sha256']:
issues.append('edition_cover_hash_mismatch')
try:
fonts = decoded_fonts(z, opf_path, root)
for binding in record.get('font_bindings', []):
if binding['original_path'] not in z.namelist() or sha(z.read(binding['original_path'])) != binding['original_sha256']:
issues.append('edition_original_font_changed')
if binding['active_path'] not in fonts or sha(fonts[binding['active_path']]) != binding['decoded_sha256']:
issues.append('edition_font_binding_mismatch')
except (ValueError, KeyError):
issues.append('edition_font_binding_invalid')
for binding in record['cover_bindings']:
path = binding['file']
if path not in z.namelist():
+11 -3
View File
@@ -93,6 +93,14 @@ def ordinal(record):
return '第'+chinese_number(record['number'])+('章' if record['role']=='chapter' else '部')
def _heading_text(tag):
"""Use image alternative text to match a publisher's graphical title."""
copy = parse_html(str(tag))
for image in copy.find_all('img'):
image.replace_with(' ' + image.get('alt', '') + ' ')
return copy.get_text(' ', strip=True)
def _headings(soup, target, label):
fragment = target.partition('#')[2]
anchor = soup.find(id=fragment) if fragment else None
@@ -114,13 +122,13 @@ def _headings(soup, target, label):
nearby = [anchor] + anchor.find_all_next(HEADINGS + ['p','div'], limit=16)
block_ids = {id(t) for t in blocks}
blocks = [t for t in nearby if id(t) in block_ids]
blocks = [t for t in blocks if any(c.isalnum() for c in plain(t.get_text()))][:12]
blocks = [t for t in blocks if any(c.isalnum() for c in _heading_text(t))][:12]
matches = []
for start in range(min(len(blocks), 6)):
for end in range(start+1, min(start+4, len(blocks))+1):
group = blocks[start:end]
if any(len(plain(t.get_text())) > 300 for t in group): break
combined = ' '.join(t.get_text(' ', strip=True) for t in group)
if any(len(_heading_text(t)) > 300 for t in group): break
combined = ' '.join(_heading_text(t) for t in group)
number_only = (not identity and len(group) == 1 and group[0].name in HEADINGS
and number_info(label)[0] is not None
and number_info(combined) == number_info(label))
+12 -1
View File
@@ -16,6 +16,7 @@ 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
@@ -266,6 +267,15 @@ def audit_pair(source, translated, manifest=None, structure=None):
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
@@ -274,7 +284,8 @@ def archive_server(paths):
archive = archives.get(prefix)
if not archive or name not in archive.namelist() or name.endswith('/'):
self.send_error(404); return
data = archive.read(name)
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 ''))
+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]