Improve EPUB restoration, book terminology and Calibre QA workflow

This commit is contained in:
Kai Tan
2026-10-08 07:25:27 +08:00
parent 40491e6386
commit caf9d8f4bb
35 changed files with 1439 additions and 959 deletions
+29 -15
View File
@@ -1,5 +1,5 @@
import shutil
import uuid
import hashlib
import zipfile
from pathlib import Path
from typing import Dict, Set
@@ -8,6 +8,7 @@ import ebooklib
from bs4 import BeautifulSoup, Tag
from ebooklib import epub
from src.common.html import parse_html, translation_targets
from src.common.data_model import BookStructure, BookMetaData, ResourceItem
from src.common.exceptions import CleaningError
from src.common.utils import setup_logger, ensure_directory
@@ -50,13 +51,14 @@ class EpubCleaner:
if media_type == ebooklib.ITEM_DOCUMENT:
# Clean HTML
content_str = item.get_content().decode('utf-8')
content_str = item.content.decode('utf-8')
cleaned_content = self._clean_html(content_str, file_name)
resources[item_id] = ResourceItem(
href=file_name,
media_type="application/xhtml+xml",
content=cleaned_content
content=cleaned_content,
properties=' '.join(getattr(item, 'properties', [])) or None
)
elif media_type in (ebooklib.ITEM_IMAGE, ebooklib.ITEM_STYLE, ebooklib.ITEM_FONT, ebooklib.ITEM_COVER):
# Save asset
@@ -68,6 +70,8 @@ class EpubCleaner:
# Preserve directory structure to avoid collisions
asset_path = self.assets_dir / file_name
if not asset_path.resolve().is_relative_to(self.assets_dir.resolve()):
raise CleaningError(f'Unsafe asset path: {file_name}')
ensure_directory(asset_path.parent)
# Ebooklib might change filenames, safer to use item.get_content()
@@ -83,6 +87,8 @@ class EpubCleaner:
# NCX or NAV document - preserve for TOC
content_bytes = item.get_content()
asset_path = self.assets_dir / file_name
if not asset_path.resolve().is_relative_to(self.assets_dir.resolve()):
raise CleaningError(f'Unsafe asset path: {file_name}')
ensure_directory(asset_path.parent)
with open(asset_path, "wb") as f:
@@ -109,6 +115,7 @@ class EpubCleaner:
# 4. Construct Structure
structure = BookStructure(
source_sha256=hashlib.sha256(self.input_path.read_bytes()).hexdigest(),
metadata=metadata,
spine=spine_ids,
resources=resources
@@ -146,26 +153,33 @@ class EpubCleaner:
return "application/octet-stream"
def _clean_html(self, content: str, filename: str) -> str:
soup = BeautifulSoup(content, 'html.parser')
soup = parse_html(content)
# 1. Provide IDs for structural/translatable elements
self._ensure_element_ids(soup)
self._ensure_element_ids(soup, filename)
# 2. Flatten divs (Disable to prevent style loss)
# self._flatten_divs(soup)
return str(soup)
def _ensure_element_ids(self, soup: BeautifulSoup):
"""
Injects UUIDs into p, h1-h6, li tags if they don't have an ID.
This provides the anchor for translation backfilling.
"""
targets = soup.find_all(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li'])
for tag in targets:
if not tag.has_attr('id'):
tag['id'] = f"uuid-{uuid.uuid4()}"
def _ensure_element_ids(self, soup, filename=''):
targets = translation_targets(soup)
used = set()
for tag in soup.find_all(attrs={'id': True}):
if tag['id'] in used:
del tag['id']
else:
used.add(tag['id'])
for index, tag in enumerate(targets):
if not tag.get('id'):
digest = hashlib.sha256(f'{filename}:{index}'.encode()).hexdigest()[:16]
candidate = f'tr-{digest}'
while candidate in used:
candidate += '-unit'
tag['id'] = candidate
used.add(candidate)
def _flatten_divs(self, soup: BeautifulSoup):
"""
Converts generic divs containing only inline text/styles to p tags.