aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-18 02:17:39 -0400
committerhistoria <historiavg@proton.me>2026-08-18 02:17:39 -0400
commite650d17b07cceb660ef8686fd1f122d50fa3a05c (patch)
tree75ef197e4d654b17459d3aa16c00e24b936da54d
parent8760626f744d79a05904e692119ffa72fc0c4e54 (diff)
downloadtts-audiobook-generator-e650d17b07cceb660ef8686fd1f122d50fa3a05c.tar.gz
refactor(converter): deduplicate epub extraction
-rw-r--r--converter/extractors.py92
-rw-r--r--tests/test_extractors.py40
2 files changed, 36 insertions, 96 deletions
diff --git a/converter/extractors.py b/converter/extractors.py
index 9139626..cd270a1 100644
--- a/converter/extractors.py
+++ b/converter/extractors.py
@@ -43,12 +43,11 @@ def extract_sections(file_path: Path) -> List[Section]:
one chapter at a time. TXT and PDF files have no chapter structure and
always yield a single section.
"""
- extension = file_path.suffix.lower()
- if extension == ".epub":
+ if file_path.suffix.lower() == ".epub":
chapters = _extract_epub_chapters(file_path)
- if len(chapters) > 1:
- return chapters
- return [Section(file_path.stem, extract_epub(file_path))]
+ if not chapters:
+ raise RuntimeError("All EPUB extraction methods failed")
+ return chapters
return [Section(file_path.stem, extract_text(file_path))]
@@ -199,49 +198,11 @@ def clean_html(html_content: str) -> str:
def extract_epub(file_path: Path) -> str:
- """Extract text from EPUB, trying several methods in order."""
- methods = [
- _extract_epub_ebooklib,
- _extract_epub_zipfile,
- _extract_epub_manual,
- ]
-
- for method in methods:
- try:
- text = method(file_path)
- if text and text.strip():
- logger.info("EPUB extraction successful (%s): %d characters", method.__name__, len(text))
- return text
- except Exception as exc:
- logger.warning("EPUB method %s failed: %s", method.__name__, exc)
-
- raise RuntimeError("All EPUB extraction methods failed")
-
-
-def _extract_epub_ebooklib(file_path: Path) -> str:
- """Extract using ebooklib, following the spine (reading) order."""
- import ebooklib
- from ebooklib import epub
-
- book = epub.read_epub(str(file_path))
- text_parts = []
-
- for entry in book.spine:
- item_id = entry[0] if isinstance(entry, (tuple, list)) else entry
- try:
- item = book.get_item_with_id(item_id)
- if item and item.get_type() == ebooklib.ITEM_DOCUMENT:
- content = item.get_body_content()
- if content:
- if isinstance(content, bytes):
- content = content.decode("utf-8", errors="ignore")
- cleaned = clean_html(str(content))
- if cleaned.strip():
- text_parts.append(cleaned)
- except Exception as exc:
- logger.debug("Skipping EPUB spine item %r: %s", item_id, exc)
-
- return "\n\n".join(text_parts)
+ """Extract the book's text from EPUB, trying several methods in order."""
+ chapters = _extract_epub_chapters(file_path)
+ if not chapters:
+ raise RuntimeError("All EPUB extraction methods failed")
+ return "\n\n".join(section.text for section in chapters)
def _natural_key(name: str):
@@ -250,41 +211,6 @@ def _natural_key(name: str):
for part in re.split(r"(\d+)", name)]
-def _extract_epub_zipfile(file_path: Path) -> str:
- """Extract by parsing HTML members of the EPUB zip directly."""
- text_parts = []
- with zipfile.ZipFile(file_path, "r") as epub_zip:
- for file_name in sorted(epub_zip.namelist(), key=_natural_key):
- if file_name.lower().endswith((".html", ".xhtml", ".htm")):
- try:
- content = epub_zip.read(file_name).decode("utf-8", errors="ignore")
- cleaned = clean_html(content)
- if cleaned.strip():
- text_parts.append(cleaned)
- except Exception as exc:
- logger.debug("Skipping EPUB member %r: %s", file_name, exc)
- return "\n\n".join(text_parts)
-
-
-def _extract_epub_manual(file_path: Path) -> str:
- """Last-resort extraction from any markup-looking EPUB member."""
- skipped_extensions = (".jpg", ".jpeg", ".png", ".gif", ".css", ".js")
- text_parts = []
- with zipfile.ZipFile(file_path, "r") as epub_zip:
- for file_name in sorted(epub_zip.namelist(), key=_natural_key):
- if file_name.lower().endswith(skipped_extensions):
- continue
- try:
- content = epub_zip.read(file_name).decode("utf-8", errors="ignore")
- if "<" in content and len(content.strip()) > 100:
- cleaned = clean_html(content)
- if cleaned:
- text_parts.append(cleaned)
- except Exception as exc:
- logger.debug("Skipping EPUB member %r: %s", file_name, exc)
- return "\n\n".join(text_parts)
-
-
def _extract_txt(file_path: Path) -> str:
"""Extract from TXT, handling BOMs and common encodings (latin-1 is the catch-all).
diff --git a/tests/test_extractors.py b/tests/test_extractors.py
index 09b688e..7b307c0 100644
--- a/tests/test_extractors.py
+++ b/tests/test_extractors.py
@@ -35,7 +35,8 @@ class TxtExtractionTests(unittest.TestCase):
extract_text(path)
-def _build_test_epub(path: Path) -> None:
+def _build_test_epub(path: Path, chapters=(("One", "First chapter text."),
+ ("Two", "Second chapter text."))) -> None:
from ebooklib import epub
book = epub.EpubBook()
@@ -43,15 +44,15 @@ def _build_test_epub(path: Path) -> None:
book.set_title("Test Book")
book.set_language("en")
- chapter1 = epub.EpubHtml(title="One", file_name="chap1.xhtml", lang="en")
- chapter1.content = "<html><body><p>First chapter text.</p></body></html>"
- chapter2 = epub.EpubHtml(title="Two", file_name="chap2.xhtml", lang="en")
- chapter2.content = "<html><body><p>Second chapter text.</p></body></html>"
+ items = []
+ for index, (title, text) in enumerate(chapters, 1):
+ chapter = epub.EpubHtml(title=title, file_name=f"chap{index}.xhtml", lang="en")
+ chapter.content = f"<html><body><p>{text}</p></body></html>"
+ book.add_item(chapter)
+ items.append(chapter)
- book.add_item(chapter1)
- book.add_item(chapter2)
- book.toc = (chapter1, chapter2)
- book.spine = ["nav", chapter1, chapter2]
+ book.toc = tuple(items)
+ book.spine = ["nav", *items]
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
@@ -68,15 +69,16 @@ class EpubExtractionTests(unittest.TestCase):
def test_ebooklib_extraction(self):
# Regression test: the ebooklib path used to silently return "" due to
# isinstance(item, ebooklib.ITEM_DOCUMENT) (an int, not a class).
- from converter.extractors import _extract_epub_ebooklib
+ from converter.extractors import _read_epub_ebooklib
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "book.epub"
_build_test_epub(path)
- text = _extract_epub_ebooklib(path)
+ items = _read_epub_ebooklib(path)
- self.assertIn("First chapter text.", text)
- self.assertIn("Second chapter text.", text)
+ html = "\n".join(content for _, content in items)
+ self.assertIn("First chapter text.", html)
+ self.assertIn("Second chapter text.", html)
def test_epub_extraction_follows_spine_order(self):
with tempfile.TemporaryDirectory() as tmp:
@@ -123,6 +125,18 @@ class ExtractSectionsTests(unittest.TestCase):
self.assertEqual(sections[0].title, "book")
self.assertEqual(sections[0].text, "Hello world.")
+ def test_single_chapter_epub_keeps_chapter_title(self):
+ from converter.extractors import extract_sections
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.epub"
+ _build_test_epub(path, chapters=(("Only", "Just one chapter."),))
+ sections = extract_sections(path)
+
+ self.assertEqual(len(sections), 1)
+ self.assertEqual(sections[0].title, "Only")
+ self.assertIn("Just one chapter.", sections[0].text)
+
if __name__ == "__main__":
unittest.main()