Files
abogen/abogen/book_parser.py
T

1071 lines
38 KiB
Python

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
def close(self):
"""Close any open file handles."""
pass
def __enter__(self):
# Already loaded in __init__, or lazily.
# Just ensure we have resources if needed, or do nothing.
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
@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 close(self):
if self.pdf_doc:
self.pdf_doc.close()
self.pdf_doc = None
def _extract_book_metadata(self):
# PDF metadata extraction can be added here if needed
# For now, base class metadata is empty dict
pass
def process_content(self, replace_single_newlines=True):
if not self.pdf_doc:
self.load()
# 1. Extract text from all pages first
for page_num in range(len(self.pdf_doc)):
text = clean_text(self.pdf_doc[page_num].get_text())
# Clean up common PDF artifacts:
text = _BRACKETED_NUMBERS_PATTERN.sub("", text)
text = _STANDALONE_PAGE_NUMBERS_PATTERN.sub("", text)
text = _PAGE_NUMBERS_AT_END_PATTERN.sub("", text)
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)
# 2. Build Navigation Structure
toc = self.pdf_doc.get_toc()
if not toc:
# Fallback: Flat list of pages if no TOC
self.processed_nav_structure = []
pages_node = {
"title": "Pages",
"src": None,
"children": [],
"has_content": False
}
# Add all pages as children
for page_num in range(len(self.pdf_doc)):
page_id = f"page_{page_num + 1}"
title = self._get_page_title(page_num, self.content_texts.get(page_id, ""))
pages_node["children"].append({
"title": title,
"src": page_id,
"children": [],
"has_content": True
})
self.processed_nav_structure.append(pages_node)
else:
self.processed_nav_structure = self._build_structure_from_toc(toc)
return self.content_texts, self.content_lengths
def _get_page_title(self, page_num, text):
title = f"Page {page_num + 1}"
if text:
first_line = text.split("\n", 1)[0].strip()
if first_line and len(first_line) < 100:
title += f" - {first_line}"
return title
def _build_structure_from_toc(self, toc):
# 1. Flatten TOC to easier list (page_num, title, level)
# fitz TOC is [[lvl, title, page, dest], ...]
bookmarks = []
for entry in toc:
lvl, title, page = entry[:3]
if isinstance(page, int):
page_idx = page - 1
else:
# Handle potential complex destinations if necessary, but usually simple int
# PyMuPDF docs say int.
page_idx = -1
if page_idx >= 0:
bookmarks.append({"level": lvl, "title": title, "page": page_idx})
root_children = []
stack = [] # Stack of (level, list_to_append_to)
stack.append((0, root_children))
# Step 1: Build the Skeleton Tree from TOC
# And keep a flat list of these nodes to associate with pages.
processed_nodes = [] # List of (page_idx, node_dict)
for entry in bookmarks:
node = {
"title": entry["title"],
"src": f"page_{entry['page'] + 1}",
"children": [],
"has_content": True
}
# Find parent
level = entry["level"]
# Adjust stack
while stack and stack[-1][0] >= level:
stack.pop()
parent_list = stack[-1][1]
parent_list.append(node)
stack.append((level, node["children"]))
processed_nodes.append((entry["page"], node))
# Step 3: Add gap pages.
# Sort processed_nodes by page index to find ranges.
sorted_bookmarks = sorted(processed_nodes, key=lambda x: x[0])
# Set of pages that are "bookmarks"
bookmarked_pages = set(p for p, n in sorted_bookmarks)
current_node = None
# We need a way to look up bookmarks starting at p
bookmarks_by_page = {}
for p, node in processed_nodes:
if p not in bookmarks_by_page:
bookmarks_by_page[p] = []
bookmarks_by_page[p].append(node)
# Let's iterate.
for page_num in range(len(self.pdf_doc)):
page_id = f"page_{page_num + 1}"
# Check if this page STARTS bookmarks
if page_num in bookmarks_by_page:
starts = bookmarks_by_page[page_num]
current_node = starts[-1]
continue
# If page is NOT a bookmark, it's a "gap page".
# Add as child to current_node
title = self._get_page_title(page_num, self.content_texts.get(page_id, ""))
page_node = {
"title": title,
"src": page_id,
"children": [],
"has_content": True
}
if current_node:
current_node["children"].append(page_node)
else:
# No preceding bookmark. Add to root.
root_children.append(page_node)
return root_children
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_element:
title = span_element.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")
# Add line breaks after block-level elements to ensure pauses in speech
for tag in slice_soup.find_all(
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
):
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}")