mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
Merge pull request #119 from mohangk/main
Extracts book_parser.py from book_handler.py
This commit is contained in:
+113
-1002
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,917 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import textwrap
|
||||||
|
import urllib.parse
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import ebooklib
|
||||||
|
from ebooklib import epub
|
||||||
|
from bs4 import BeautifulSoup, NavigableString
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
from abogen.utils import detect_encoding
|
||||||
|
from abogen.subtitle_utils import clean_text, calculate_text_length
|
||||||
|
|
||||||
|
# Pre-compile frequently used regex patterns
|
||||||
|
_BRACKETED_NUMBERS_PATTERN = re.compile(r"\[\s*\d+\s*\]")
|
||||||
|
_STANDALONE_PAGE_NUMBERS_PATTERN = re.compile(r"^\s*\d+\s*$", re.MULTILINE)
|
||||||
|
_PAGE_NUMBERS_AT_END_PATTERN = re.compile(r"\s+\d+\s*$", re.MULTILINE)
|
||||||
|
_PAGE_NUMBERS_WITH_DASH_PATTERN = re.compile(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseBookParser(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for parsing different book formats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, book_path):
|
||||||
|
self.book_path = os.path.normpath(os.path.abspath(book_path))
|
||||||
|
self.content_texts = {}
|
||||||
|
self.content_lengths = {}
|
||||||
|
self.book_metadata = {}
|
||||||
|
# Unified structure for navigation: list of dicts
|
||||||
|
# { 'title': str, 'src': str, 'children': [], 'has_content': bool }
|
||||||
|
self.processed_nav_structure = []
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def load(self):
|
||||||
|
"""Load the book file."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process_content(self, replace_single_newlines=True):
|
||||||
|
"""Process the book content to extract text and structure."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def file_type(self):
|
||||||
|
"""Return the type of the file (pdf, epub, markdown)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_chapters(self):
|
||||||
|
"""Return a list of chapter IDs and Names."""
|
||||||
|
chapters = []
|
||||||
|
if self.processed_nav_structure:
|
||||||
|
|
||||||
|
def flatten_nav(nodes):
|
||||||
|
for node in nodes:
|
||||||
|
if node.get("has_content"):
|
||||||
|
chapters.append((node["src"], node["title"]))
|
||||||
|
if node.get("children"):
|
||||||
|
flatten_nav(node["children"])
|
||||||
|
|
||||||
|
flatten_nav(self.processed_nav_structure)
|
||||||
|
else:
|
||||||
|
# Fallback for simple content without nav structure
|
||||||
|
for ch_id, content in self.content_texts.items():
|
||||||
|
# This could be improved, but serves as a generic fallback
|
||||||
|
chapters.append((ch_id, ch_id))
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
def get_formatted_text(self):
|
||||||
|
"""
|
||||||
|
Returns the full text of the book formatted with chapter markers.
|
||||||
|
"""
|
||||||
|
chapters = self.get_chapters()
|
||||||
|
full_text = []
|
||||||
|
|
||||||
|
for chapter_id, chapter_name in chapters:
|
||||||
|
text = self.content_texts.get(chapter_id, "")
|
||||||
|
if text:
|
||||||
|
full_text.append(f"\n<<CHAPTER_MARKER:{chapter_name}>>\n")
|
||||||
|
full_text.append(text)
|
||||||
|
|
||||||
|
return "\n".join(full_text)
|
||||||
|
|
||||||
|
def get_metadata(self):
|
||||||
|
"""Return extracted metadata."""
|
||||||
|
return self.book_metadata
|
||||||
|
|
||||||
|
|
||||||
|
class PdfParser(BaseBookParser):
|
||||||
|
def __init__(self, book_path):
|
||||||
|
self.pdf_doc = None
|
||||||
|
super().__init__(book_path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_type(self):
|
||||||
|
return "pdf"
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
try:
|
||||||
|
self.pdf_doc = fitz.open(self.book_path)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error loading PDF {self.book_path}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def process_content(self, replace_single_newlines=True):
|
||||||
|
if not self.pdf_doc:
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
for page_num in range(len(self.pdf_doc)):
|
||||||
|
text = clean_text(self.pdf_doc[page_num].get_text())
|
||||||
|
|
||||||
|
# Clean up common PDF artifacts:
|
||||||
|
# - Remove bracketed numbers often used for citations [1]
|
||||||
|
text = _BRACKETED_NUMBERS_PATTERN.sub("", text)
|
||||||
|
# - Remove standalone page numbers often found in headers/footers
|
||||||
|
text = _STANDALONE_PAGE_NUMBERS_PATTERN.sub("", text)
|
||||||
|
# - Remove page numbers at end of lines
|
||||||
|
text = _PAGE_NUMBERS_AT_END_PATTERN.sub("", text)
|
||||||
|
# - Remove page numbers with dashes - 4 -
|
||||||
|
text = _PAGE_NUMBERS_WITH_DASH_PATTERN.sub("", text)
|
||||||
|
|
||||||
|
page_id = f"page_{page_num + 1}"
|
||||||
|
self.content_texts[page_id] = text
|
||||||
|
self.content_lengths[page_id] = calculate_text_length(text)
|
||||||
|
|
||||||
|
return self.content_texts, self.content_lengths
|
||||||
|
|
||||||
|
def _extract_book_metadata(self):
|
||||||
|
# PDF metadata extraction can be added here if needed
|
||||||
|
# For now, base class metadata is empty dict
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_chapters(self):
|
||||||
|
# PDF specific implementation because it doesn't use nav structure
|
||||||
|
chapters = []
|
||||||
|
if self.pdf_doc:
|
||||||
|
for i in range(len(self.pdf_doc)):
|
||||||
|
chapters.append((f"page_{i+1}", f"Page {i+1}"))
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownParser(BaseBookParser):
|
||||||
|
def __init__(self, book_path):
|
||||||
|
self.markdown_text = None
|
||||||
|
super().__init__(book_path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_type(self):
|
||||||
|
return "markdown"
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
try:
|
||||||
|
encoding = detect_encoding(self.book_path)
|
||||||
|
with open(self.book_path, "r", encoding=encoding, errors="replace") as f:
|
||||||
|
self.markdown_text = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error reading markdown file: {e}")
|
||||||
|
self.markdown_text = ""
|
||||||
|
|
||||||
|
def process_content(self, replace_single_newlines=True):
|
||||||
|
if self.markdown_text is None:
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
self._process_markdown_content()
|
||||||
|
return self.content_texts, self.content_lengths
|
||||||
|
|
||||||
|
def _convert_markdown_toc_to_nav(self, toc_tokens):
|
||||||
|
nav_nodes = []
|
||||||
|
for token in toc_tokens:
|
||||||
|
node = {
|
||||||
|
"title": token["name"],
|
||||||
|
"src": token["id"],
|
||||||
|
"children": self._convert_markdown_toc_to_nav(
|
||||||
|
token.get("children", [])
|
||||||
|
),
|
||||||
|
"has_content": True,
|
||||||
|
}
|
||||||
|
nav_nodes.append(node)
|
||||||
|
return nav_nodes
|
||||||
|
|
||||||
|
def _process_markdown_content(self):
|
||||||
|
if not self.markdown_text:
|
||||||
|
return
|
||||||
|
|
||||||
|
original_text = textwrap.dedent(self.markdown_text)
|
||||||
|
md = markdown.Markdown(extensions=["toc", "fenced_code"])
|
||||||
|
html = md.convert(original_text)
|
||||||
|
markdown_toc = md.toc_tokens
|
||||||
|
|
||||||
|
# Convert markdown TOC tokens to our unified navigation structure
|
||||||
|
self.processed_nav_structure = self._convert_markdown_toc_to_nav(markdown_toc)
|
||||||
|
|
||||||
|
cleaned_full_text = clean_text(original_text)
|
||||||
|
|
||||||
|
# If no TOC found, treat as single chapter
|
||||||
|
if not self.processed_nav_structure:
|
||||||
|
chapter_id = "markdown_content"
|
||||||
|
self.content_texts[chapter_id] = cleaned_full_text
|
||||||
|
self.content_lengths[chapter_id] = calculate_text_length(cleaned_full_text)
|
||||||
|
return
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
all_headers = []
|
||||||
|
|
||||||
|
def flatten_nav_internal(nodes):
|
||||||
|
for node in nodes:
|
||||||
|
all_headers.append(node)
|
||||||
|
if node.get("children"):
|
||||||
|
flatten_nav_internal(node["children"])
|
||||||
|
|
||||||
|
flatten_nav_internal(self.processed_nav_structure)
|
||||||
|
|
||||||
|
header_positions = []
|
||||||
|
for node in all_headers:
|
||||||
|
header_id = node["src"]
|
||||||
|
id_pattern = f'id="{header_id}"'
|
||||||
|
pos = html.find(id_pattern)
|
||||||
|
if pos != -1:
|
||||||
|
tag_start = html.rfind("<", 0, pos)
|
||||||
|
header_positions.append(
|
||||||
|
{"id": header_id, "start": tag_start, "name": node["title"]}
|
||||||
|
)
|
||||||
|
header_positions.sort(key=lambda x: x["start"])
|
||||||
|
|
||||||
|
for i, header_pos in enumerate(header_positions):
|
||||||
|
header_id = header_pos["id"]
|
||||||
|
header_name = header_pos["name"]
|
||||||
|
content_start = header_pos["start"]
|
||||||
|
|
||||||
|
content_end = (
|
||||||
|
header_positions[i + 1]["start"]
|
||||||
|
if i + 1 < len(header_positions)
|
||||||
|
else len(html)
|
||||||
|
)
|
||||||
|
section_html = html[content_start:content_end]
|
||||||
|
section_soup = BeautifulSoup(section_html, "html.parser")
|
||||||
|
|
||||||
|
header_tag = section_soup.find(attrs={"id": header_id})
|
||||||
|
if header_tag:
|
||||||
|
header_tag.decompose()
|
||||||
|
|
||||||
|
section_text = clean_text(section_soup.get_text()).strip()
|
||||||
|
chapter_id = header_id
|
||||||
|
if section_text:
|
||||||
|
full_content = f"{header_name}\n\n{section_text}"
|
||||||
|
self.content_texts[chapter_id] = full_content
|
||||||
|
self.content_lengths[chapter_id] = calculate_text_length(full_content)
|
||||||
|
else:
|
||||||
|
self.content_texts[chapter_id] = header_name
|
||||||
|
self.content_lengths[chapter_id] = calculate_text_length(header_name)
|
||||||
|
|
||||||
|
def get_chapters(self):
|
||||||
|
chapters = super().get_chapters()
|
||||||
|
if not chapters and "markdown_content" in self.content_texts:
|
||||||
|
chapters.append(("markdown_content", "Content"))
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
class EpubParser(BaseBookParser):
|
||||||
|
def __init__(self, book_path):
|
||||||
|
self.book = None
|
||||||
|
self.doc_content = {}
|
||||||
|
super().__init__(book_path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_type(self):
|
||||||
|
return "epub"
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
try:
|
||||||
|
self.book = epub.read_epub(self.book_path)
|
||||||
|
except KeyError as e:
|
||||||
|
# TODO: should we just patch the ebooklib pre-emptively to avoid the need to catch this exception?
|
||||||
|
logging.warning(f"EPUB missing referenced file: {e}. Attempting to patch.")
|
||||||
|
# Patch ebooklib to skip missing files
|
||||||
|
import types
|
||||||
|
from ebooklib import epub as _epub_module
|
||||||
|
|
||||||
|
reader_class = _epub_module.EpubReader
|
||||||
|
orig_read_file = reader_class.read_file
|
||||||
|
|
||||||
|
def safe_read_file(self, name):
|
||||||
|
try:
|
||||||
|
return orig_read_file(self, name)
|
||||||
|
except KeyError:
|
||||||
|
logging.warning(f"Missing file in EPUB: {name}. Returning empty bytes.")
|
||||||
|
return b""
|
||||||
|
|
||||||
|
reader_class.read_file = safe_read_file
|
||||||
|
try:
|
||||||
|
self.book = epub.read_epub(self.book_path)
|
||||||
|
finally:
|
||||||
|
reader_class.read_file = orig_read_file
|
||||||
|
|
||||||
|
def process_content(self, replace_single_newlines=True):
|
||||||
|
if not self.book:
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
self.book_metadata = self._extract_book_metadata()
|
||||||
|
try:
|
||||||
|
nav_item, nav_type = self._identify_nav_item()
|
||||||
|
self._execute_nav_parsing_logic(nav_item, nav_type)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"EPUB nav processing failed: {e}. Falling back to spine.")
|
||||||
|
self._process_epub_content_spine_fallback()
|
||||||
|
|
||||||
|
return self.content_texts, self.content_lengths
|
||||||
|
|
||||||
|
def _extract_book_metadata(self):
|
||||||
|
metadata = {}
|
||||||
|
if not self.book:
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata["title"] = self.book.get_metadata("DC", "title")[0][0]
|
||||||
|
except Exception:
|
||||||
|
metadata["title"] = os.path.splitext(os.path.basename(self.book_path))[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata["author"] = self.book.get_metadata("DC", "creator")[0][0]
|
||||||
|
except Exception:
|
||||||
|
metadata["author"] = "Unknown Author"
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata["language"] = self.book.get_metadata("DC", "language")[0][0]
|
||||||
|
except Exception:
|
||||||
|
metadata["language"] = "en"
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def _find_doc_key(self, base_href, doc_order, doc_order_decoded):
|
||||||
|
candidates = [
|
||||||
|
base_href,
|
||||||
|
urllib.parse.unquote(base_href),
|
||||||
|
]
|
||||||
|
base_name = os.path.basename(base_href).lower()
|
||||||
|
for k in list(doc_order.keys()) + list(doc_order_decoded.keys()):
|
||||||
|
if os.path.basename(k).lower() == base_name:
|
||||||
|
candidates.append(k)
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in doc_order:
|
||||||
|
return candidate, doc_order[candidate]
|
||||||
|
elif candidate in doc_order_decoded:
|
||||||
|
return candidate, doc_order_decoded[candidate]
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _find_position_robust(self, doc_href, fragment_id):
|
||||||
|
if doc_href not in self.doc_content:
|
||||||
|
logging.warning(f"Document '{doc_href}' not found in cached content.")
|
||||||
|
return 0
|
||||||
|
html_content = self.doc_content[doc_href]
|
||||||
|
if not fragment_id:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_soup = BeautifulSoup(f"<div>{html_content}</div>", "html.parser")
|
||||||
|
target_element = temp_soup.find(id=fragment_id)
|
||||||
|
if target_element:
|
||||||
|
tag_str = str(target_element)
|
||||||
|
pos = html_content.find(tag_str[: min(len(tag_str), 200)])
|
||||||
|
if pos != -1:
|
||||||
|
return pos
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"BeautifulSoup failed to find id='{fragment_id}': {e}")
|
||||||
|
|
||||||
|
safe_fragment_id = re.escape(fragment_id)
|
||||||
|
id_name_pattern = re.compile(
|
||||||
|
f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']", re.IGNORECASE
|
||||||
|
)
|
||||||
|
match = id_name_pattern.search(html_content)
|
||||||
|
if match:
|
||||||
|
return match.start()
|
||||||
|
|
||||||
|
id_match_str = f'id="{fragment_id}"'
|
||||||
|
name_match_str = f'name="{fragment_id}"'
|
||||||
|
id_pos = html_content.find(id_match_str)
|
||||||
|
name_pos = html_content.find(name_match_str)
|
||||||
|
|
||||||
|
pos = -1
|
||||||
|
if id_pos != -1 and name_pos != -1:
|
||||||
|
pos = min(id_pos, name_pos)
|
||||||
|
elif id_pos != -1:
|
||||||
|
pos = id_pos
|
||||||
|
elif name_pos != -1:
|
||||||
|
pos = name_pos
|
||||||
|
|
||||||
|
if pos != -1:
|
||||||
|
tag_start_pos = html_content.rfind("<", 0, pos)
|
||||||
|
final_pos = tag_start_pos if tag_start_pos != -1 else 0
|
||||||
|
return final_pos
|
||||||
|
|
||||||
|
logging.warning(
|
||||||
|
f"Anchor '{fragment_id}' not found in {doc_href}. Defaulting to position 0."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _parse_ncx_navpoint(
|
||||||
|
self,
|
||||||
|
nav_point,
|
||||||
|
ordered_entries,
|
||||||
|
doc_order,
|
||||||
|
doc_order_decoded,
|
||||||
|
tree_structure_list,
|
||||||
|
find_position_func,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Recursive parsing of NCX navigation nodes.
|
||||||
|
|
||||||
|
Logic tested by: tests/test_epub_ncx_parsing.py
|
||||||
|
"""
|
||||||
|
nav_label = nav_point.find("navLabel")
|
||||||
|
content = nav_point.find("content")
|
||||||
|
title = (
|
||||||
|
nav_label.find("text").get_text(strip=True)
|
||||||
|
if nav_label and nav_label.find("text")
|
||||||
|
else "Untitled Section"
|
||||||
|
)
|
||||||
|
src = content["src"] if content and "src" in content.attrs else None
|
||||||
|
|
||||||
|
current_entry_node = {"title": title, "src": src, "children": []}
|
||||||
|
|
||||||
|
if src:
|
||||||
|
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
|
||||||
|
doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded)
|
||||||
|
if not doc_key:
|
||||||
|
current_entry_node["has_content"] = False
|
||||||
|
else:
|
||||||
|
position = find_position_func(doc_key, fragment)
|
||||||
|
entry_data = {
|
||||||
|
"src": src,
|
||||||
|
"title": title,
|
||||||
|
"doc_href": doc_key,
|
||||||
|
"position": position,
|
||||||
|
"doc_order": doc_idx,
|
||||||
|
}
|
||||||
|
ordered_entries.append(entry_data)
|
||||||
|
current_entry_node["has_content"] = True
|
||||||
|
else:
|
||||||
|
current_entry_node["has_content"] = False
|
||||||
|
|
||||||
|
child_navpoints = nav_point.find_all("navPoint", recursive=False)
|
||||||
|
if child_navpoints:
|
||||||
|
for child_np in child_navpoints:
|
||||||
|
self._parse_ncx_navpoint(
|
||||||
|
child_np,
|
||||||
|
ordered_entries,
|
||||||
|
doc_order,
|
||||||
|
doc_order_decoded,
|
||||||
|
current_entry_node["children"],
|
||||||
|
find_position_func,
|
||||||
|
)
|
||||||
|
|
||||||
|
if title and (
|
||||||
|
current_entry_node.get("has_content", False) or current_entry_node["children"]
|
||||||
|
):
|
||||||
|
tree_structure_list.append(current_entry_node)
|
||||||
|
|
||||||
|
def _extract_nav_li_title(self, li_element, link_element=None, span_element=None):
|
||||||
|
"""Helper to extract title from a nav <li> element, handling various structures."""
|
||||||
|
title = "Untitled Section"
|
||||||
|
|
||||||
|
if link_element:
|
||||||
|
title = link_element.get_text(strip=True) or title
|
||||||
|
elif span_element:
|
||||||
|
title = span_element.get_text(strip=True) or title
|
||||||
|
|
||||||
|
# Fallback to direct text if title is empty or default
|
||||||
|
# If we used link/span but got empty string, we try fallback.
|
||||||
|
# If we didn't use link/span, we try fallback.
|
||||||
|
if not title.strip() or title == "Untitled Section":
|
||||||
|
li_text = "".join(
|
||||||
|
t for t in li_element.contents if isinstance(t, NavigableString)
|
||||||
|
).strip()
|
||||||
|
if li_text:
|
||||||
|
title = li_text
|
||||||
|
|
||||||
|
# Second fallback: if we have a span but title is still empty, try span text again
|
||||||
|
# (covered by logic above mostly, but mirroring original logic's intense fallback)
|
||||||
|
if (not title.strip() or title == "Untitled Section") and span_text:
|
||||||
|
title = span_text.get_text(strip=True) or title
|
||||||
|
|
||||||
|
return title
|
||||||
|
|
||||||
|
def _parse_html_nav_li(
|
||||||
|
self,
|
||||||
|
li_element,
|
||||||
|
ordered_entries,
|
||||||
|
doc_order,
|
||||||
|
doc_order_decoded,
|
||||||
|
tree_structure_list,
|
||||||
|
find_position_func,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Recursive parsing of HTML5 Navigation (li) nodes.
|
||||||
|
|
||||||
|
Logic tested by: tests/test_epub_html_nav_parsing.py
|
||||||
|
"""
|
||||||
|
link = li_element.find("a", recursive=False)
|
||||||
|
span_text = li_element.find("span", recursive=False)
|
||||||
|
src = None
|
||||||
|
current_entry_node = {"children": []}
|
||||||
|
|
||||||
|
if link and "href" in link.attrs:
|
||||||
|
src = link["href"]
|
||||||
|
|
||||||
|
title = self._extract_nav_li_title(li_element, link, span_text)
|
||||||
|
|
||||||
|
current_entry_node["title"] = title
|
||||||
|
current_entry_node["src"] = src
|
||||||
|
|
||||||
|
doc_key = None
|
||||||
|
doc_idx = None
|
||||||
|
position = 0
|
||||||
|
fragment = None
|
||||||
|
if src:
|
||||||
|
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
|
||||||
|
doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded)
|
||||||
|
if doc_key is not None:
|
||||||
|
position = find_position_func(doc_key, fragment)
|
||||||
|
entry_data = {
|
||||||
|
"src": src,
|
||||||
|
"title": title,
|
||||||
|
"doc_href": doc_key,
|
||||||
|
"position": position,
|
||||||
|
"doc_order": doc_idx,
|
||||||
|
}
|
||||||
|
ordered_entries.append(entry_data)
|
||||||
|
current_entry_node["has_content"] = True
|
||||||
|
else:
|
||||||
|
current_entry_node["has_content"] = False
|
||||||
|
else:
|
||||||
|
current_entry_node["has_content"] = False
|
||||||
|
|
||||||
|
for child_ol in li_element.find_all("ol", recursive=False):
|
||||||
|
for child_li in child_ol.find_all("li", recursive=False):
|
||||||
|
self._parse_html_nav_li(
|
||||||
|
child_li,
|
||||||
|
ordered_entries,
|
||||||
|
doc_order,
|
||||||
|
doc_order_decoded,
|
||||||
|
current_entry_node["children"],
|
||||||
|
find_position_func,
|
||||||
|
)
|
||||||
|
tree_structure_list.append(current_entry_node)
|
||||||
|
|
||||||
|
def _identify_nav_item(self):
|
||||||
|
"""Identify the navigation item (HTML Nav or NCX) and its type."""
|
||||||
|
nav_item = None
|
||||||
|
nav_type = None
|
||||||
|
|
||||||
|
# 1. Check ITEM_NAVIGATION
|
||||||
|
nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION))
|
||||||
|
|
||||||
|
# 1.1 Support for EPUB 3 EpubNav which might be ITEM_DOCUMENT (9) but with properties=['nav']
|
||||||
|
if not nav_items:
|
||||||
|
# Look in ITEM_DOCUMENT for items with 'nav' property
|
||||||
|
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||||
|
if hasattr(item, 'get_type') and item.get_type() == ebooklib.ITEM_DOCUMENT:
|
||||||
|
# Check properties - ebooklib stores opf properties in list
|
||||||
|
# Some versions use item.properties, some need checking
|
||||||
|
props = getattr(item, 'properties', [])
|
||||||
|
if 'nav' in props:
|
||||||
|
nav_items.append(item)
|
||||||
|
|
||||||
|
if nav_items:
|
||||||
|
nav_item = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in nav_items
|
||||||
|
if "nav" in item.get_name().lower()
|
||||||
|
and item.get_name().lower().endswith((".xhtml", ".html"))
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
) or next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in nav_items
|
||||||
|
if item.get_name().lower().endswith((".xhtml", ".html"))
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if nav_item:
|
||||||
|
nav_type = "html"
|
||||||
|
|
||||||
|
# 2. NCX in NAV
|
||||||
|
if not nav_item and nav_items:
|
||||||
|
ncx_in_nav = next(
|
||||||
|
(item for item in nav_items if item.get_name().lower().endswith(".ncx")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if ncx_in_nav:
|
||||||
|
nav_item = ncx_in_nav
|
||||||
|
nav_type = "ncx"
|
||||||
|
|
||||||
|
# 3. ITEM_NCX or Fallback
|
||||||
|
# If no explicit navigation item found, try to find a standard NCX file
|
||||||
|
if not nav_item:
|
||||||
|
ncx_constant = getattr(epub, "ITEM_NCX", None)
|
||||||
|
if ncx_constant is not None:
|
||||||
|
ncx_items = list(self.book.get_items_of_type(ncx_constant))
|
||||||
|
if ncx_items:
|
||||||
|
nav_item = ncx_items[0]
|
||||||
|
nav_type = "ncx"
|
||||||
|
|
||||||
|
# 4. Heuristic Search
|
||||||
|
# Scan documents for something that looks like a TOC if standard methods fail
|
||||||
|
if not nav_item:
|
||||||
|
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||||
|
try:
|
||||||
|
html_content = item.get_content().decode("utf-8", errors="ignore")
|
||||||
|
if "<nav" in html_content and 'epub:type="toc"' in html_content:
|
||||||
|
nav_item = item
|
||||||
|
nav_type = "html"
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not nav_item or not nav_type:
|
||||||
|
raise ValueError("No navigation document found")
|
||||||
|
|
||||||
|
return nav_item, nav_type
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_nav_parsing_logic(self, nav_item, nav_type):
|
||||||
|
"""Parse the identified navigation item and slice content accordingly."""
|
||||||
|
|
||||||
|
parser_type = "html.parser" if nav_type == "html" else "xml"
|
||||||
|
try:
|
||||||
|
nav_content = nav_item.get_content().decode("utf-8", errors="ignore")
|
||||||
|
nav_soup = BeautifulSoup(nav_content, parser_type)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to parse navigation content: {e}")
|
||||||
|
|
||||||
|
self.doc_content = {}
|
||||||
|
spine_docs = []
|
||||||
|
for spine_item_tuple in self.book.spine:
|
||||||
|
item_id = spine_item_tuple[0]
|
||||||
|
item = self.book.get_item_with_id(item_id)
|
||||||
|
if item:
|
||||||
|
spine_docs.append(item.get_name())
|
||||||
|
doc_order = {href: i for i, href in enumerate(spine_docs)}
|
||||||
|
doc_order_decoded = {
|
||||||
|
urllib.parse.unquote(href): i for href, i in doc_order.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.content_texts = {}
|
||||||
|
self.content_lengths = {}
|
||||||
|
|
||||||
|
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||||
|
href = item.get_name()
|
||||||
|
if href in doc_order or any(
|
||||||
|
href in nav_point.get("src", "")
|
||||||
|
for nav_point in nav_soup.find_all(["content", "a"])
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
self.doc_content[href] = item.get_content().decode(
|
||||||
|
"utf-8", errors="ignore"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.doc_content[href] = ""
|
||||||
|
|
||||||
|
ordered_nav_entries = []
|
||||||
|
parse_successful = False
|
||||||
|
|
||||||
|
if nav_type == "ncx":
|
||||||
|
nav_map = nav_soup.find("navMap")
|
||||||
|
if nav_map:
|
||||||
|
for nav_point in nav_map.find_all("navPoint", recursive=False):
|
||||||
|
self._parse_ncx_navpoint(
|
||||||
|
nav_point,
|
||||||
|
ordered_nav_entries,
|
||||||
|
doc_order,
|
||||||
|
doc_order_decoded,
|
||||||
|
self.processed_nav_structure,
|
||||||
|
self._find_position_robust,
|
||||||
|
)
|
||||||
|
parse_successful = bool(ordered_nav_entries)
|
||||||
|
elif nav_type == "html":
|
||||||
|
toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"})
|
||||||
|
if not toc_nav:
|
||||||
|
for nav in nav_soup.find_all("nav"):
|
||||||
|
if nav.find("ol"):
|
||||||
|
toc_nav = nav
|
||||||
|
break
|
||||||
|
if toc_nav:
|
||||||
|
top_ol = toc_nav.find("ol", recursive=False)
|
||||||
|
if top_ol:
|
||||||
|
for li in top_ol.find_all("li", recursive=False):
|
||||||
|
self._parse_html_nav_li(
|
||||||
|
li,
|
||||||
|
ordered_nav_entries,
|
||||||
|
doc_order,
|
||||||
|
doc_order_decoded,
|
||||||
|
self.processed_nav_structure,
|
||||||
|
self._find_position_robust,
|
||||||
|
)
|
||||||
|
parse_successful = bool(ordered_nav_entries)
|
||||||
|
|
||||||
|
if not parse_successful:
|
||||||
|
raise ValueError("No valid navigation entries found after parsing")
|
||||||
|
|
||||||
|
ordered_nav_entries.sort(key=lambda x: (x["doc_order"], x["position"]))
|
||||||
|
|
||||||
|
num_entries = len(ordered_nav_entries)
|
||||||
|
for i in range(num_entries):
|
||||||
|
current_entry = ordered_nav_entries[i]
|
||||||
|
current_src = current_entry["src"]
|
||||||
|
current_doc = current_entry["doc_href"]
|
||||||
|
current_pos = current_entry["position"]
|
||||||
|
current_doc_html = self.doc_content.get(current_doc, "")
|
||||||
|
|
||||||
|
start_slice_pos = current_pos
|
||||||
|
slice_html = ""
|
||||||
|
|
||||||
|
next_entry = ordered_nav_entries[i + 1] if (i + 1) < num_entries else None
|
||||||
|
|
||||||
|
if next_entry:
|
||||||
|
next_doc = next_entry["doc_href"]
|
||||||
|
next_pos = next_entry["position"]
|
||||||
|
|
||||||
|
if current_doc == next_doc:
|
||||||
|
slice_html = current_doc_html[start_slice_pos:next_pos]
|
||||||
|
else:
|
||||||
|
slice_html = current_doc_html[start_slice_pos:]
|
||||||
|
docs_between = []
|
||||||
|
try:
|
||||||
|
idx_current = spine_docs.index(current_doc)
|
||||||
|
idx_next = spine_docs.index(next_doc)
|
||||||
|
if idx_current < idx_next:
|
||||||
|
docs_between = [
|
||||||
|
spine_docs[k] for k in range(idx_current + 1, idx_next)
|
||||||
|
]
|
||||||
|
elif idx_current > idx_next:
|
||||||
|
docs_between = [
|
||||||
|
spine_docs[k]
|
||||||
|
for k in range(idx_current + 1, len(spine_docs))
|
||||||
|
]
|
||||||
|
docs_between.extend(
|
||||||
|
[spine_docs[k] for k in range(0, idx_next)]
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for doc_href in docs_between:
|
||||||
|
slice_html += self.doc_content.get(doc_href, "")
|
||||||
|
next_doc_html = self.doc_content.get(next_doc, "")
|
||||||
|
slice_html += next_doc_html[:next_pos]
|
||||||
|
else:
|
||||||
|
slice_html = current_doc_html[start_slice_pos:]
|
||||||
|
try:
|
||||||
|
idx_current = spine_docs.index(current_doc)
|
||||||
|
for doc_idx in range(idx_current + 1, len(spine_docs)):
|
||||||
|
slice_html += self.doc_content.get(spine_docs[doc_idx], "")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not slice_html.strip() and current_doc_html:
|
||||||
|
slice_html = current_doc_html
|
||||||
|
|
||||||
|
if slice_html.strip():
|
||||||
|
slice_soup = BeautifulSoup(slice_html, "html.parser")
|
||||||
|
for tag in slice_soup.find_all(["p", "div"]):
|
||||||
|
tag.append("\n\n")
|
||||||
|
|
||||||
|
for ol in slice_soup.find_all("ol"):
|
||||||
|
start = int(ol.get("start", 1))
|
||||||
|
for idx, li in enumerate(ol.find_all("li", recursive=False)):
|
||||||
|
number_text = f"{start + idx}) "
|
||||||
|
if li.string:
|
||||||
|
li.string.replace_with(number_text + li.string)
|
||||||
|
else:
|
||||||
|
li.insert(0, NavigableString(number_text))
|
||||||
|
|
||||||
|
for tag in slice_soup.find_all(["sup", "sub"]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
text = clean_text(slice_soup.get_text()).strip()
|
||||||
|
if text:
|
||||||
|
self.content_texts[current_src] = text
|
||||||
|
self.content_lengths[current_src] = calculate_text_length(text)
|
||||||
|
else:
|
||||||
|
self.content_texts[current_src] = ""
|
||||||
|
self.content_lengths[current_src] = 0
|
||||||
|
else:
|
||||||
|
self.content_texts[current_src] = ""
|
||||||
|
self.content_lengths[current_src] = 0
|
||||||
|
|
||||||
|
if ordered_nav_entries:
|
||||||
|
first_entry = ordered_nav_entries[0]
|
||||||
|
first_doc_href = first_entry["doc_href"]
|
||||||
|
first_pos = first_entry["position"]
|
||||||
|
first_doc_order = first_entry["doc_order"]
|
||||||
|
prefix_html = ""
|
||||||
|
|
||||||
|
for doc_idx in range(first_doc_order):
|
||||||
|
if doc_idx < len(spine_docs):
|
||||||
|
intermediate_doc_href = spine_docs[doc_idx]
|
||||||
|
prefix_html += self.doc_content.get(intermediate_doc_href, "")
|
||||||
|
|
||||||
|
first_doc_html = self.doc_content.get(first_doc_href, "")
|
||||||
|
prefix_html += first_doc_html[:first_pos]
|
||||||
|
|
||||||
|
if prefix_html.strip():
|
||||||
|
prefix_soup = BeautifulSoup(prefix_html, "html.parser")
|
||||||
|
for tag in prefix_soup.find_all(["sup", "sub"]):
|
||||||
|
tag.decompose()
|
||||||
|
prefix_text = clean_text(prefix_soup.get_text()).strip()
|
||||||
|
|
||||||
|
if prefix_text:
|
||||||
|
prefix_chapter_src = "internal:prefix_content"
|
||||||
|
self.content_texts[prefix_chapter_src] = prefix_text
|
||||||
|
self.content_lengths[prefix_chapter_src] = len(prefix_text)
|
||||||
|
self.processed_nav_structure.insert(
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
"src": prefix_chapter_src,
|
||||||
|
"title": "Introduction",
|
||||||
|
"children": [],
|
||||||
|
"has_content": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_epub_content_spine_fallback(self):
|
||||||
|
"""
|
||||||
|
Process EPUB content using the spine (linear reading order)
|
||||||
|
when navigation processing fails.
|
||||||
|
"""
|
||||||
|
logging.info("Using spine fallback for EPUB processing.")
|
||||||
|
self.doc_content = {}
|
||||||
|
spine_docs = []
|
||||||
|
for spine_item_tuple in self.book.spine:
|
||||||
|
item_id = spine_item_tuple[0]
|
||||||
|
item = self.book.get_item_with_id(item_id)
|
||||||
|
if item:
|
||||||
|
spine_docs.append(item.get_name())
|
||||||
|
else:
|
||||||
|
logging.warning(f"Spine item with id '{item_id}' not found.")
|
||||||
|
|
||||||
|
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||||
|
href = item.get_name()
|
||||||
|
if href in spine_docs:
|
||||||
|
try:
|
||||||
|
html_content = item.get_content().decode("utf-8", errors="ignore")
|
||||||
|
self.doc_content[href] = html_content
|
||||||
|
except Exception:
|
||||||
|
self.doc_content[href] = ""
|
||||||
|
|
||||||
|
self.content_texts = {}
|
||||||
|
self.content_lengths = {}
|
||||||
|
for i, doc_href in enumerate(spine_docs):
|
||||||
|
html_content = self.doc_content.get(doc_href, "")
|
||||||
|
if html_content:
|
||||||
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
|
||||||
|
# Handle ordered lists
|
||||||
|
for ol in soup.find_all("ol"):
|
||||||
|
start = int(ol.get("start", 1))
|
||||||
|
for idx, li in enumerate(ol.find_all("li", recursive=False)):
|
||||||
|
number_text = f"{start + idx}) "
|
||||||
|
if li.string:
|
||||||
|
li.string.replace_with(number_text + li.string)
|
||||||
|
else:
|
||||||
|
li.insert(0, NavigableString(number_text))
|
||||||
|
|
||||||
|
# Remove sup/sub
|
||||||
|
for tag in soup.find_all(["sup", "sub"]):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
text = clean_text(soup.get_text()).strip()
|
||||||
|
if text:
|
||||||
|
self.content_texts[doc_href] = text
|
||||||
|
self.content_lengths[doc_href] = calculate_text_length(text)
|
||||||
|
|
||||||
|
def get_chapters(self):
|
||||||
|
chapters = super().get_chapters()
|
||||||
|
if not chapters:
|
||||||
|
# Use spine order fallback if no Nav structure
|
||||||
|
if self.book:
|
||||||
|
for spine_item_tuple in self.book.spine:
|
||||||
|
item_id = spine_item_tuple[0]
|
||||||
|
item = self.book.get_item_with_id(item_id)
|
||||||
|
if item:
|
||||||
|
href = item.get_name()
|
||||||
|
if href in self.content_texts:
|
||||||
|
chapters.append((href, href))
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
def get_book_parser(book_path, file_type=None):
|
||||||
|
"""
|
||||||
|
Factory function to get the appropriate parser instance.
|
||||||
|
"""
|
||||||
|
book_path = os.path.normpath(os.path.abspath(book_path))
|
||||||
|
|
||||||
|
if not file_type:
|
||||||
|
if book_path.lower().endswith(".pdf"):
|
||||||
|
file_type = "pdf"
|
||||||
|
elif book_path.lower().endswith((".md", ".markdown")):
|
||||||
|
file_type = "markdown"
|
||||||
|
else:
|
||||||
|
file_type = "epub"
|
||||||
|
|
||||||
|
if file_type == "pdf":
|
||||||
|
return PdfParser(book_path)
|
||||||
|
elif file_type == "markdown":
|
||||||
|
return MarkdownParser(book_path)
|
||||||
|
elif file_type == "epub":
|
||||||
|
return EpubParser(book_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported file type: {file_type}")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user