feat: Additive merge of webui branch with PyQt GUI support

- Add abogen/pyqt/ package with full PyQt6 desktop GUI
- Restore PyQt GUI files (gui.py, book_handler.py, queue_manager_gui.py, voice_formula_gui.py, conversion.py)
- Add new shared modules from webui: book_parser.py, subtitle_utils.py, spacy_utils.py
- Add Linux libraries (libxcb-cursor) for Qt platform plugin support
- Add new epub parsing tests from webui branch
- Update pyproject.toml with dual entry points:
  - abogen: PyQt6 desktop GUI
  - abogen-web: Flask Web UI
- Add PyQt6 to dependencies
- Re-export PyQt classes from root modules for backwards compatibility
- Merge CHANGELOG.md entries (1.2.0-1.2.5 from webui)
- Update README.md with dual interface documentation

Implements #26 - shared core with separate UI folders
This commit is contained in:
JB
2025-12-22 05:51:21 -08:00
parent ede5343e0c
commit e2b2f610a6
32 changed files with 14863 additions and 38 deletions
+84
View File
@@ -0,0 +1,84 @@
import unittest
import os
import sys
import shutil
import time
from PyQt6.QtWidgets import QApplication
# Ensure we can import the module
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from abogen.book_handler import HandlerDialog
from ebooklib import epub
# We need a QApplication instance for QWriter/QDialog
app = QApplication(sys.argv)
class TestBookHandlerRegression(unittest.TestCase):
def setUp(self):
self.test_dir = "tests/test_data_handler"
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.sample_epub_path = os.path.join(self.test_dir, "test_book.epub")
self._create_sample_epub()
def tearDown(self):
HandlerDialog.clear_content_cache()
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def _create_sample_epub(self):
book = epub.EpubBook()
book.set_identifier("id123456")
book.set_title("Sample Book")
book.set_language("en")
c1 = epub.EpubHtml(title="Intro", file_name="intro.xhtml", lang="en")
c1.content = "<h1>Introduction</h1><p>Welcome to the book.</p>"
book.add_item(c1)
book.spine = ["nav", c1]
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
epub.write_epub(self.sample_epub_path, book)
def test_handler_initialization(self):
"""Test that HandlerDialog processes the book correctly."""
# HandlerDialog starts processing in a background thread in __init__
# We assume headless environment, so we won't show it.
# But we need to wait for the thread to finish.
dialog = HandlerDialog(self.sample_epub_path)
# Wait for thread to finish
# The dialog emits no signal publicly, but we can check internal state or thread
start_time = time.time()
while time.time() - start_time < 5:
# HandlerDialog logic:
# _loader_thread.finished connect to _on_load_finished
# _on_load_finished populates content_texts and content_lengths
# We can check if content_texts is populated
if dialog.content_texts:
break
app.processEvents() # Process Qt events to let thread signals propagate
time.sleep(0.1)
self.assertTrue(len(dialog.content_texts) > 0, "HandlerDialog failed to process content in time")
# Validate content similar to what we expect
# intro.xhtml should be there
found_intro = False
for key, text in dialog.content_texts.items():
if "Welcome to the book" in text:
found_intro = True
break
self.assertTrue(found_intro)
# Cleanup
dialog.close()
if __name__ == "__main__":
unittest.main()
+210
View File
@@ -0,0 +1,210 @@
import unittest
import os
import sys
import shutil
import fitz # PyMuPDF
from ebooklib import epub
# Ensure we can import the module
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from abogen.book_parser import get_book_parser, PdfParser, EpubParser, MarkdownParser
class TestBookParser(unittest.TestCase):
def setUp(self):
self.test_dir = "tests/test_data"
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.sample_pdf_path = os.path.join(self.test_dir, "test_book.pdf")
self.sample_epub_path = os.path.join(self.test_dir, "test_book.epub")
self.sample_md_path = os.path.join(self.test_dir, "test_book.md")
self._create_sample_pdf()
self._create_sample_epub()
self._create_sample_md()
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def _create_sample_pdf(self):
doc = fitz.open()
# Page 1
page1 = doc.new_page()
page1.insert_text((50, 50), "Page 1 content")
# Add pattern to be cleaned
page1.insert_text((50, 100), "[12]")
page1.insert_text((50, 200), "1") # Page number at bottom
# Page 2
page2 = doc.new_page()
page2.insert_text((50, 50), "Page 2 content")
doc.save(self.sample_pdf_path)
doc.close()
def _create_sample_epub(self):
book = epub.EpubBook()
book.set_identifier("id123456")
book.set_title("Sample Book")
book.set_language("en")
book.add_author("Test Author")
c1 = epub.EpubHtml(title="Intro", file_name="intro.xhtml", lang="en")
c1.content = "<h1>Introduction</h1><p>Welcome to the book.</p>"
c2 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c2.content = "<h1>Chapter 1</h1><ol><li>Item One</li><li>Item Two</li></ol>"
book.add_item(c1)
book.add_item(c2)
# Basic spine and nav
book.spine = ["nav", c1, c2]
# Add NCX and NAV for compatibility
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
epub.write_epub(self.sample_epub_path, book)
def _create_sample_md(self):
content = "# Chapter 1\nSome text.\n# Chapter 2\nMore text."
with open(self.sample_md_path, "w") as f:
f.write(content)
def test_factory_returns_correct_class(self):
"""Test that get_book_parser returns the correct subclass based on extension."""
parser_pdf = get_book_parser(self.sample_pdf_path)
self.assertIsInstance(parser_pdf, PdfParser)
parser_md = get_book_parser(self.sample_md_path)
self.assertIsInstance(parser_md, MarkdownParser)
parser_epub = get_book_parser(self.sample_epub_path)
self.assertIsInstance(parser_epub, EpubParser)
def test_factory_explicit_type(self):
"""Test that explicit file type argument overrides extension."""
# 1. Copy sample epub to something.pdf
wrong_ext_path = os.path.join(self.test_dir, "actually_epub.pdf")
shutil.copy(self.sample_epub_path, wrong_ext_path)
# 2. Open it telling parser it IS epub
parser = get_book_parser(wrong_ext_path, file_type="epub")
self.assertIsInstance(parser, EpubParser)
# Should load successfully
parser.load()
self.assertTrue(parser.book is not None)
def test_pdf_parser_content(self):
"""Test PdfParser content extraction."""
parser = get_book_parser(self.sample_pdf_path)
parser.process_content()
self.assertIn("page_1", parser.content_texts)
self.assertIn("page_2", parser.content_texts)
text1 = parser.content_texts["page_1"]
self.assertIn("Page 1 content", text1)
self.assertNotIn("[12]", text1)
def test_markdown_parser_content(self):
"""Test MarkdownParser splitting logic."""
parser = get_book_parser(self.sample_md_path)
parser.process_content()
# Should have Chapter 1 and Chapter 2 keys (actual keys depend on ID generation)
# Markdown extensions might slugify IDs: "chapter-1"
self.assertIn("chapter-1", parser.content_texts)
self.assertIn("chapter-2", parser.content_texts)
self.assertIn("Some text", parser.content_texts["chapter-1"])
def test_epub_parser_content(self):
"""Test EpubParser processing."""
parser = get_book_parser(self.sample_epub_path)
parser.process_content()
self.assertIn("intro.xhtml", parser.content_texts)
self.assertIn("chap1.xhtml", parser.content_texts)
self.assertIn("Welcome to the book", parser.content_texts["intro.xhtml"])
def test_epub_metadata_extraction(self):
"""Test metadata extraction in EpubParser."""
parser = get_book_parser(self.sample_epub_path)
# Processing content triggers metadata extraction in current implementation
parser.process_content()
metadata = parser.get_metadata()
self.assertEqual(metadata.get("title"), "Sample Book")
self.assertEqual(metadata.get("author"), "Test Author")
def test_ordered_list_handling(self):
"""Test <ol> handling in EpubParser."""
parser = get_book_parser(self.sample_epub_path)
parser.process_content()
text = parser.content_texts.get("chap1.xhtml", "")
self.assertIn("1) Item One", text)
self.assertIn("2) Item Two", text)
def test_find_position_robust_logic(self):
"""Unit test for _find_position_robust on EpubParser."""
parser = EpubParser(self.sample_epub_path) # Instantiate directly
html = '<html><body><p>Start</p><h1 id="target">Heading</h1><p>End</p></body></html>'
parser.doc_content["dummy.html"] = html
# Test finding ID
pos = parser._find_position_robust("dummy.html", "target")
self.assertGreater(pos, 0)
self.assertTrue(html[pos:].startswith('<h1 id="target"'))
# Test missing ID
pos_missing = parser._find_position_robust("dummy.html", "missing")
self.assertEqual(pos_missing, 0)
def test_get_chapters(self):
"""Test get_chapters returns correct list for different parsers."""
# PDF
parser_pdf = get_book_parser(self.sample_pdf_path)
chapters = parser_pdf.get_chapters()
self.assertEqual(len(chapters), 2)
self.assertEqual(chapters[0], ("page_1", "Page 1"))
# MD
parser_md = get_book_parser(self.sample_md_path)
parser_md.process_content() # Must process to get structure
chapters_md = parser_md.get_chapters()
# Expecting chapter-1, chapter-2
ids = [c[0] for c in chapters_md]
self.assertIn("chapter-1", ids)
def test_get_formatted_text(self):
"""Test formatting of full text via BaseBookParser method."""
parser = get_book_parser(self.sample_md_path)
parser.process_content()
text = parser.get_formatted_text()
self.assertIn("<<CHAPTER_MARKER:Chapter 1>>", text)
self.assertIn("Some text", text)
def test_file_type_property(self):
"""Test that file_type property returns correct string for each parser."""
pdf_parser = PdfParser(self.sample_pdf_path)
self.assertEqual(pdf_parser.file_type, "pdf")
epub_parser = EpubParser(self.sample_epub_path)
self.assertEqual(epub_parser.file_type, "epub")
md_parser = MarkdownParser(self.sample_md_path)
self.assertEqual(md_parser.file_type, "markdown")
if __name__ == "__main__":
unittest.main()
+199
View File
@@ -0,0 +1,199 @@
import unittest
import os
import shutil
import sys
from ebooklib import epub
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from abogen.book_parser import get_book_parser
class TestEpubContentSlicing(unittest.TestCase):
"""
Tests for the complex content slicing logic in _execute_nav_parsing_logic.
This covers scenarios where multiple chapters/sections are contained within
a single physical HTML file, separated by anchors (fragments).
"""
def setUp(self):
self.test_dir = "tests/test_data_slicing"
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.epub_path = os.path.join(self.test_dir, "slicing_test.epub")
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def test_single_file_multiple_chapters(self):
"""
Test splitting one XHTML file into two chapters using an anchor.
"""
book = epub.EpubBook()
book.set_identifier("slice123")
book.set_title("Slicing Test Book")
# Create a single content file with two sections
content_html = """
<html>
<body>
<h1 id="chap1">Chapter 1</h1>
<p>Text for chapter 1.</p>
<hr/>
<h1 id="chap2">Chapter 2</h1>
<p>Text for chapter 2.</p>
</body>
</html>
"""
c1 = epub.EpubHtml(title="Full Content", file_name="content.xhtml", lang="en")
c1.content = content_html
book.add_item(c1)
# Create Nav that points to anchors in the SAME file
# We use EpubHtml for Nav to control content exactly without ebooklib interference
nav_html = """
<nav epub:type="toc" id="toc">
<ol>
<li><a href="content.xhtml#chap1">Chapter 1</a></li>
<li><a href="content.xhtml#chap2">Chapter 2</a></li>
</ol>
</nav>
"""
nav = epub.EpubHtml(title="Nav", file_name="nav.xhtml")
nav.content = nav_html
book.add_item(nav)
book.spine = [nav, c1]
epub.write_epub(self.epub_path, book)
# OPF Patching to valid crash
import zipfile
patched = False
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
if 'toc="ncx"' in opf_content:
opf_content = opf_content.replace('toc="ncx"', '')
patched = True
if patched:
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
if patched:
shutil.move(TEMP_EPUB, self.epub_path)
# Parse
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
# Filter Nav/Intro
chapters = [c for c in chapters if "Chapter" in c[1]]
self.assertEqual(len(chapters), 2)
self.assertEqual(chapters[0][1], "Chapter 1")
self.assertEqual(chapters[1][1], "Chapter 2")
# Check content of Chapter 1
# It should contain "Text for chapter 1" but NOT "Text for chapter 2"
# The parser logic slices from start_pos to next_pos
text1 = parser.content_texts[chapters[0][0]]
self.assertIn("Text for chapter 1", text1)
self.assertNotIn("Text for chapter 2", text1)
# Check content of Chapter 2
text2 = parser.content_texts[chapters[1][0]]
self.assertIn("Text for chapter 2", text2)
def test_list_renumbering(self):
"""
Test that ordered lists are re-numbered when slicing.
The parser has logic to reset <ol start="..."> or insert numbers.
"""
book = epub.EpubBook()
book.set_identifier("list123")
book.set_title("List Test Book")
content_html = """
<html>
<body>
<h1 id="part1">Part 1</h1>
<ol>
<li>Item A</li>
<li>Item B</li>
</ol>
<h1 id="part2">Part 2</h1>
<ol start="3">
<li>Item C</li>
<li>Item D</li>
</ol>
</body>
</html>
"""
c1 = epub.EpubHtml(title="Content", file_name="content.xhtml", lang="en")
c1.content = content_html
book.add_item(c1)
nav_html = """
<nav epub:type="toc">
<ol>
<li><a href="content.xhtml#part1">Part 1</a></li>
<li><a href="content.xhtml#part2">Part 2</a></li>
</ol>
</nav>
"""
nav = epub.EpubHtml(title="Nav", file_name="nav.xhtml")
nav.content = nav_html
book.add_item(nav)
book.spine = [nav, c1]
epub.write_epub(self.epub_path, book)
# Patch
import zipfile
patched = False
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
if 'toc="ncx"' in opf_content:
opf_content = opf_content.replace('toc="ncx"', '')
patched = True
if patched:
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
if patched:
shutil.move(TEMP_EPUB, self.epub_path)
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
chapters = [c for c in chapters if "Part" in c[1]]
self.assertEqual(len(chapters), 2)
# Check Part 1 text
text1 = parser.content_texts[chapters[0][0]]
# The parser explicitly replaces li with "1) Item A" style text
self.assertIn("1) Item A", text1)
self.assertIn("2) Item B", text1)
# Check Part 2 text
text2 = parser.content_texts[chapters[1][0]]
# Should convert start="3" to "3) Item C"
self.assertIn("3) Item C", text2)
self.assertIn("4) Item D", text2)
if __name__ == "__main__":
unittest.main()
+117
View File
@@ -0,0 +1,117 @@
import unittest
import os
import shutil
import sys
from ebooklib import epub
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from abogen.book_parser import get_book_parser
class TestEpubHeuristicNav(unittest.TestCase):
"""
Tests for the heuristic fallback in _identify_nav_item (Step 4),
where the parser scans ITEM_DOCUMENTs for <nav epub:type="toc">
when no explicit ITEM_NAVIGATION is found.
"""
def setUp(self):
self.test_dir = "tests/test_data_heuristic"
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.epub_path = os.path.join(self.test_dir, "heuristic_test.epub")
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def test_heuristic_nav_discovery(self):
book = epub.EpubBook()
book.set_identifier("heuristic123")
book.set_title("Heuristic Test Book")
# 1. Add Content
c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c1.content = "<h1>Chapter 1</h1><p>Text</p>"
book.add_item(c1)
# 2. Add a Nav file BUT as a regular EpubHtml (ITEM_DOCUMENT)
# We do NOT use EpubNav. We do NOT look like a standard nav file if possible,
# but content must contain the magical signature.
nav_content = """
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<body>
<nav epub:type="toc" id="toc">
<h1>Hidden TOC</h1>
<ol>
<li><a href="chap1.xhtml">Chapter 1</a></li>
</ol>
</nav>
</body>
</html>
"""
# Filename intentionally generic/obscure to avoid filename-based heuristics
# (though current code checks content, not just filename)
nav_file = epub.EpubHtml(title="Hidden Nav", file_name="content_toc.xhtml")
nav_file.content = nav_content
book.add_item(nav_file)
# 3. Setup Spine
book.spine = [nav_file, c1]
# 4. Write EPUB
epub.write_epub(self.epub_path, book)
# 5. Patch OPF to ensure ebooklib didn't sneakily add ITEM_NAVIGATION or toc="ncx"
import zipfile
patched = False
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
# Remove toc="ncx" attribute if present (causes crash if no NCX)
if 'toc="ncx"' in opf_content:
opf_content = opf_content.replace('toc="ncx"', '')
patched = True
# Ideally we'd verify properties="nav" isn't there, but EpubHtml shouldn't add it.
# If ebooklib added it, we might need to strip it to force heuristic.
if 'properties="nav"' in opf_content:
opf_content = opf_content.replace('properties="nav"', '')
patched = True
if patched:
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
if patched:
shutil.move(TEMP_EPUB, self.epub_path)
# 6. Verify our setup: Ensure NO ITEM_NAVIGATION exists
# We can inspect using ebooklib again
import ebooklib
check_book = epub.read_epub(self.epub_path)
nav_items = list(check_book.get_items_of_type(ebooklib.ITEM_NAVIGATION))
self.assertEqual(len(nav_items), 0, "Setup failed: explicit navigation item found!")
# 7. Run Parser
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
# 8. Assertions
# Should have found the nav via content scanning
chapter_titles = [c[1] for c in chapters]
self.assertIn("Chapter 1", chapter_titles)
# Also verify we hit the "html" type in identification
# We can't easily check private variables, but success implies it worked.
if __name__ == "__main__":
unittest.main()
+184
View File
@@ -0,0 +1,184 @@
import unittest
import os
import shutil
import sys
from ebooklib import epub
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from abogen.book_parser import get_book_parser
class TestEpubHtmlNavParsing(unittest.TestCase):
"""
Tests for EPUB 3 HTML5 Navigation Document parsing logic (_parse_html_nav_li).
"""
def setUp(self):
self.test_dir = "tests/test_data_nav"
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.epub_path = os.path.join(self.test_dir, "nav_test.epub")
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def _create_epub_with_custom_nav(self, nav_html_content):
"""
Creates an EPUB with a manually injected HTML Navigation Document.
"""
book = epub.EpubBook()
book.set_identifier("navtest123")
book.set_title("Nav Test Book")
# Add some content files
c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c1.content = "<h1>Chapter 1</h1><p>Text 1</p>"
book.add_item(c1)
c2 = epub.EpubHtml(title="Chapter 2", file_name="chap2.xhtml", lang="en")
c2.content = "<h1>Chapter 2</h1><p>Text 2</p>"
book.add_item(c2)
# Create the Nav item manually to control the HTML structure exactly
# Use EpubHtml + OPF patching because EpubNav forces auto-generation
nav = epub.EpubHtml(title="Nav", file_name="nav.xhtml")
nav.content = nav_html_content
book.add_item(nav)
# We must set spine manually
book.spine = [nav, c1, c2]
epub.write_epub(self.epub_path, book)
# Patch the OPF to remove toc="ncx" default which causes crash
# because we intentionally excluded the legacy NCX file.
import zipfile
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
opf_content = opf_content.replace('toc="ncx"', '')
# Repack
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
shutil.move(TEMP_EPUB, self.epub_path)
def test_basic_html_nav_parsing(self):
"""
Test parsing of a standard flat list of links.
"""
nav_html = """
<nav epub:type="toc" id="toc">
<h1>Table of Contents</h1>
<ol>
<li><a href="chap1.xhtml">Chapter 1</a></li>
<li><a href="chap2.xhtml">Chapter 2</a></li>
</ol>
</nav>
"""
self._create_epub_with_custom_nav(nav_html)
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
# Filter out "Nav" or "Introduction" prefix content found from the Nav file itself
chapters = [c for c in chapters if "Chapter" in c[1] or "Section" in c[1]]
self.assertEqual(len(chapters), 2)
self.assertEqual(chapters[0][1], "Chapter 1")
self.assertEqual(chapters[1][1], "Chapter 2")
def test_nested_html_nav_parsing(self):
"""
Test parsing of nested lists (Sub-chapters).
"""
nav_html = """
<nav epub:type="toc">
<ol>
<li>
<a href="chap1.xhtml">Chapter 1</a>
<ol>
<li><a href="chap2.xhtml">Section 1.1</a></li>
</ol>
</li>
</ol>
</nav>
"""
# Note: In this test setup, chap2 is serving as "Section 1.1" effectively
self._create_epub_with_custom_nav(nav_html)
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
ids = [c[1] for c in chapters]
self.assertIn("Chapter 1", ids)
self.assertIn("Section 1.1", ids)
def test_span_header_parsing(self):
"""
Test parsing of <li><span>Header</span><ol>...</ol></li> pattern.
This represents a grouping header that isn't a link itself.
"""
nav_html = """
<nav epub:type="toc">
<ol>
<li>
<span>Part I</span>
<ol>
<li><a href="chap1.xhtml">Chapter 1</a></li>
</ol>
</li>
</ol>
</nav>
"""
self._create_epub_with_custom_nav(nav_html)
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
chapter_titles = [c[1] for c in chapters]
self.assertIn("Chapter 1", chapter_titles)
self.assertNotIn("Part I", chapter_titles)
# Check internal structure
# Find the node named "Part I" in the processed structure
root_node = next(node for node in parser.processed_nav_structure if node['title'] == "Part I")
self.assertEqual(root_node['title'], "Part I")
self.assertFalse(root_node['has_content'])
self.assertEqual(len(root_node['children']), 1)
self.assertEqual(root_node['children'][0]['title'], "Chapter 1")
def test_identify_nav_item(self):
"""Test the _identify_nav_item method specifically."""
nav_html = """
<nav epub:type="toc" id="toc"><h1>TOC</h1><ol><li><a href="c1.html">C1</a></li></ol></nav>
"""
self._create_epub_with_custom_nav(nav_html)
parser = get_book_parser(self.epub_path)
# Note: _identify_nav_item relies on self.book being loaded
# The parser constructor or process_content handles load()
# But here we can call load directly if needed, or rely on normal flow up until navigation
parser.load()
nav_item, nav_type = parser._identify_nav_item()
self.assertEqual(nav_type, "html")
self.assertIsNotNone(nav_item)
self.assertTrue("nav.xhtml" in nav_item.get_name())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,100 @@
import unittest
import os
import shutil
import zipfile
import sys
import logging
from ebooklib import epub
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from abogen.book_parser import get_book_parser
class TestEpubMissingFileErrorHandling(unittest.TestCase):
"""
Tests for robust error handling and recovery in the book parser.
"""
def setUp(self):
self.test_dir = "tests/test_data_errors"
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.broken_epub_path = os.path.join(self.test_dir, "missing_file.epub")
# Suppress logging during tests to keep output clean,
# or capture it if we want to assert on warnings.
# For now, we just let it be or set level to ERROR.
logging.getLogger().setLevel(logging.ERROR)
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def _create_broken_epub(self):
"""
Creates an EPUB where a file listed in the manifest is missing from the ZIP archive.
"""
book = epub.EpubBook()
book.set_identifier("broken123")
book.set_title("Broken Book")
# 1. Add a valid chapter
c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c1.content = "<h1>Chapter 1</h1><p>Survivable content.</p>"
book.add_item(c1)
# 2. Add a 'ghost' chapter that we will delete later
c2 = epub.EpubHtml(title="Ghost Chapter", file_name="ghost.xhtml", lang="en")
c2.content = "<h1>Ghost</h1><p>I will disappear.</p>"
book.add_item(c2)
book.spine = ["nav", c1, c2]
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
temp_path = os.path.join(self.test_dir, "temp.epub")
epub.write_epub(temp_path, book)
# 3. Physically remove 'ghost.xhtml' from the ZIP
with zipfile.ZipFile(temp_path, 'r') as zin:
with zipfile.ZipFile(self.broken_epub_path, 'w') as zout:
for item in zin.infolist():
# Copy everything EXCEPT the ghost file
# Note: ebooklib might put files in OEPS/ or EPUB/ folders depending on version,
# so checking "ghost.xhtml" presence in filename is safer.
if "ghost.xhtml" not in item.filename:
zout.writestr(item, zin.read(item.filename))
def test_missing_file_recovery(self):
"""
Verify that the parser recovers gracefully when a referenced file is missing.
Should log a warning instead of raising KeyError.
"""
self._create_broken_epub()
try:
parser = get_book_parser(self.broken_epub_path)
parser.process_content()
# 1. Ensure process didn't crash
self.assertTrue(True, "Parser should not crash on missing file")
# 2. Ensure valid content was extracted
# Identify the ID for chap1.xhtml (usually file path based)
# Since IDs can vary, we check if ANY content contains our known string
chap1_found = False
for text in parser.content_texts.values():
if "Survivable content" in text:
chap1_found = True
break
self.assertTrue(chap1_found, "The valid chapter should still be processed")
except KeyError:
self.fail("Parser raised KeyError instead of handling the missing file!")
except Exception as e:
self.fail(f"Parser raised unexpected exception: {e}")
if __name__ == "__main__":
unittest.main()
+140
View File
@@ -0,0 +1,140 @@
import unittest
import os
import shutil
import sys
from ebooklib import epub
# Ensure we can import the module
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from abogen.book_parser import get_book_parser, EpubParser
class TestEpubNcxParsing(unittest.TestCase):
"""
Focused tests for NCX navigation scenarios, ensuring legacy/compatibility
modes work when HTML5 Navigation is missing.
"""
def setUp(self):
self.test_dir = "tests/test_data_ncx"
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.ncx_only_epub_path = os.path.join(self.test_dir, "ncx_only.epub")
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def _create_ncx_only_epub(self, chapters):
"""
Helper to create an EPUB with ONLY NCX table of contents (no HTML nav).
"""
book = epub.EpubBook()
book.set_identifier("ncx_test_123")
book.set_title("NCX Only Book")
book.set_language("en")
epub_chapters = []
for i, (title, content) in enumerate(chapters):
filename = f"chap{i+1}.xhtml"
c = epub.EpubHtml(title=title, file_name=filename, lang="en")
# Ensure content is substantial enough to not be skipped
c.content = f"<h1>{title}</h1><p>{content}</p>"
book.add_item(c)
epub_chapters.append(c)
# Define Table of Contents
book.toc = tuple(epub_chapters)
# Add default NCX and generic spine
book.add_item(epub.EpubNcx())
# IMPORTANT: Do NOT add EpubNav() here, that's what we are testing!
book.spine = ["nav"] + epub_chapters
epub.write_epub(self.ncx_only_epub_path, book)
def test_ncx_only_parsing(self):
"""
Verify that an EPUB with only an NCX file (no HTML nav) is parsed correctly.
Logic tested: _process_epub_content_nav (NCX branch), _parse_ncx_navpoint
"""
# 1. Setup Data
chapters_data = [
("Chapter 1", "This is the first chapter."),
("Chapter 2", "This is the second chapter.")
]
self._create_ncx_only_epub(chapters_data)
# 2. Run Parser
parser = get_book_parser(self.ncx_only_epub_path)
parser.process_content()
# 3. Verify Breakdown
# We expect detailed breakdown based on NCX
chapters = parser.get_chapters()
# Should find exactly 2 chapters based on the Toc
self.assertEqual(len(chapters), 2, "Should have 2 chapters extracted from NCX")
# Check Titles and Sequence
self.assertEqual(chapters[0][1], "Chapter 1")
self.assertEqual(chapters[1][1], "Chapter 2")
# Verify content was extracted
# Note: 'src' in chapters usually points to file_name if no fragments
id_1 = chapters[0][0]
self.assertIn("This is the first chapter", parser.content_texts[id_1])
def test_nested_ncx_parsing(self):
"""
Verify parsing of nested NCX structures (Chapters with Subchapters).
"""
book = epub.EpubBook()
book.set_identifier("nested_ncx")
book.set_title("Nested NCX")
# Create one big file with sections
c1 = epub.EpubHtml(title="Main Chapter", file_name="main.xhtml", lang="en")
c1.content = """
<h1 id="intro">Introduction</h1>
<p>Intro text.</p>
<h2 id="sect1">Section 1</h2>
<p>Section 1 text.</p>
"""
book.add_item(c1)
# Manually construct nested TOC because ebooklib's default helpers are simple
# EbookLib automatically builds NCX from book.toc
# Nested tuple structure: (Section, (Subsection, Sub-subsection))
# We need to link to Fragments for this to really test nested NCX pointing to same file
# EbookLib Link object: epub.Link(href, title, uid)
link_root = epub.Link("main.xhtml#intro", "Introduction", "intro")
link_sect = epub.Link("main.xhtml#sect1", "Section 1", "sect1")
# Structure: Intro -> Section 1 (as child)
book.toc = (
(link_root, (link_sect, )),
)
book.add_item(epub.EpubNcx())
book.spine = ["nav", c1]
epub.write_epub(self.ncx_only_epub_path, book)
# Parse
parser = get_book_parser(self.ncx_only_epub_path)
parser.process_content()
chapters = parser.get_chapters()
# Depending on how the parser flattens, we should see both entries
titles = [node[1] for node in chapters]
self.assertIn("Introduction", titles)
self.assertIn("Section 1", titles)
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
import unittest
import os
import shutil
import sys
from ebooklib import epub
import ebooklib
from unittest.mock import MagicMock
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from abogen.book_parser import get_book_parser
class TestEpubStandardNav(unittest.TestCase):
"""
Tests for the standard ITEM_NAVIGATION discovery in _identify_nav_item.
Refactored to explicitly test different discovery paths defined in the parser.
"""
def setUp(self):
self.test_dir = "tests/test_data_standard_nav"
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.epub_path = os.path.join(self.test_dir, "standard_nav_test.epub")
def tearDown(self):
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def _create_and_load_epub(self):
"""Helper to create a basic EPUB and return a loaded parser."""
book = epub.EpubBook()
book.set_identifier("stdnav123")
book.set_title("Standard Nav Test")
c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c1.content = "<h1>Chapter 1</h1><p>Text 1</p>"
book.add_item(c1)
# Use Standard EpubNav
nav = epub.EpubNav()
book.add_item(nav)
book.spine = [nav, c1]
epub.write_epub(self.epub_path, book)
# "Zip Surgery" Patch:
# ebooklib unconditionally adds `toc="ncx"` to the spine, even for EPUB 3 files that purely use HTML Nav.
# This creates a dangling reference to a non-existent "ncx" item, causing ebooklib to crash on read.
# We manually remove this attribute to ensure the test EPUB is valid and readable.
# TODO - find real world examples of EPUB 3 files that use HTML Nav
import zipfile
patched = False
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
if 'toc="ncx"' in opf_content:
opf_content = opf_content.replace('toc="ncx"', '')
patched = True
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
if patched:
shutil.move(TEMP_EPUB, self.epub_path)
parser = get_book_parser(self.epub_path)
parser.load()
return parser
def test_discovery_by_item_navigation_type(self):
"""
Scenario 1: The item is explicitly identified as ITEM_NAVIGATION (4).
This exercises the first branch of _identify_nav_item.
"""
parser = self._create_and_load_epub()
# Inject an item that mocks the ITEM_NAVIGATION type behavior
# (This simulates a library/parser that correctly types the item as 4)
mock_nav = MagicMock()
mock_nav.get_name.return_value = "nav.xhtml"
mock_nav.get_type.return_value = ebooklib.ITEM_NAVIGATION
# We append this mock to the book items to ensure get_items_of_type(ITEM_NAVIGATION) finds it
parser.book.items.append(mock_nav)
nav_item, nav_type = parser._identify_nav_item()
self.assertEqual(nav_type, "html")
self.assertEqual(nav_item.get_name(), "nav.xhtml")
# Verify we are getting the object we expect (implied by success)
def test_discovery_by_nav_property(self):
"""
Scenario 2: The item is ITEM_DOCUMENT (9) but has properties=['nav'].
This is the standard EPUB 3 behavior and exercises the fallback branch.
"""
parser = self._create_and_load_epub()
# Locate the generic 'nav' item loaded by ebooklib
original_nav = parser.book.get_item_with_id("nav")
self.assertIsNotNone(original_nav)
# "Fix" the object to match what we expect from a correct EPUB 3 read:
# It should have properties=['nav'].
# We use a real EpubNav object to ensure structural correctness.
proper_nav = epub.EpubNav(uid=original_nav.id, file_name=original_nav.file_name)
proper_nav.content = original_nav.content
proper_nav.properties = ['nav']
# Swap it into the book items list
try:
idx = parser.book.items.index(original_nav)
parser.book.items[idx] = proper_nav
except ValueError:
self.fail("Could not find original nav item to swap")
nav_item, nav_type = parser._identify_nav_item()
self.assertEqual(nav_type, "html")
self.assertEqual(nav_item.get_name(), "nav.xhtml")
# Check that we actually found the one with properties
self.assertEqual(getattr(nav_item, 'properties', []), ['nav'])
if __name__ == "__main__":
unittest.main()