diff --git a/CHANGELOG.md b/CHANGELOG.md
index a987142..37a0963 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
- Added profile system to voice mixer, allowing users to create and manage multiple voice profiles.
- Improvements in the voice mixer, mostly for organizing controls and enhancing user experience.
- Added icons for flags and genders in the GUI, making it easier to identify different options.
+- Improved the content and chapter extraction process for EPUB files, ensuring better handling of various structures.
- Switched to platformdirs for determining the correct desktop path, instead of using old methods.
- Fixed preview voices was not using GPU acceleration, which was causing performance issues.
- Improvements in code and documentation.
\ No newline at end of file
diff --git a/README.md b/README.md
index 55abb0c..4ee7998 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
## `Voice Mixer`
-With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to @jborza for making this possible through his contributions in #5)
+With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5))
## `Supported Languages`
```
@@ -168,8 +168,9 @@ Feel free to explore the code and make any changes you like.
## `Credits`
- Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible.
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows.
+- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files.
- Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface.
-- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id), [Person](https://icons8.com/icon/34105/person) icons by [Icons8](https://icons8.com/).
+- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id) icons by [Icons8](https://icons8.com/).
## `License`
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
diff --git a/abogen/VERSION b/abogen/VERSION
index e6d5cb8..e4c0d46 100644
--- a/abogen/VERSION
+++ b/abogen/VERSION
@@ -1 +1 @@
-1.0.2
\ No newline at end of file
+1.0.3
\ No newline at end of file
diff --git a/abogen/book_handler.py b/abogen/book_handler.py
index 3248f3d..fb79b9d 100644
--- a/abogen/book_handler.py
+++ b/abogen/book_handler.py
@@ -4,7 +4,7 @@ import ebooklib
import base64
import fitz # PyMuPDF for PDF support
from ebooklib import epub
-from bs4 import BeautifulSoup
+from bs4 import BeautifulSoup, NavigableString
from PyQt5.QtWidgets import (
QDialog,
QTreeWidget,
@@ -24,6 +24,13 @@ from PyQt5.QtWidgets import (
from PyQt5.QtCore import Qt
from utils import clean_text, calculate_text_length
import os
+import logging # Add logging
+import urllib.parse
+
+# Setup logging
+logging.basicConfig(
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
+)
class HandlerDialog(QDialog):
@@ -59,7 +66,37 @@ class HandlerDialog(QDialog):
self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end
# Load the book based on file type
- self.book = epub.read_epub(book_path) if self.file_type == "epub" else None
+ try:
+ self.book = epub.read_epub(book_path) if self.file_type == "epub" else None
+ except KeyError as e:
+ logging.error(
+ f"EPUB file is missing a referenced file: {e}. Skipping missing file."
+ )
+ # Try to patch ebooklib to skip missing files (monkey-patch read_file)
+ import types
+
+ orig_read_file = None
+ try:
+ 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
+ self.book = epub.read_epub(book_path)
+ reader_class.read_file = orig_read_file # Restore
+ except Exception as patch_e:
+ logging.error(f"Failed to patch ebooklib for missing files: {patch_e}")
+ raise e
self.pdf_doc = fitz.open(book_path) if self.file_type == "pdf" else None
# Extract book metadata
@@ -129,250 +166,18 @@ class HandlerDialog(QDialog):
def _preprocess_content(self):
"""Pre-process content from the document"""
if self.file_type == "epub":
- # Always process EPUB content using the anchor-based approach
- self._process_epub_content()
+ try:
+ self._process_epub_content_nav() # Use the new navigation-based method
+ except Exception as e:
+ logging.error(
+ f"Error processing EPUB with navigation: {e}. Falling back to TOC/spine.",
+ exc_info=True,
+ )
+ # Fallback to a simpler spine-based processing if nav fails
+ self._process_epub_content_spine_fallback()
else:
self._preprocess_pdf_content()
- def _process_epub_content(self, split_anchors=True):
- """
- Process EPUB content by globally ordering TOC entries and slicing content between them.
- Ensures all content between defined TOC start points is captured.
- """
- # split_anchors parameter kept for compatibility but always treated as True
- book = self.book
-
- # 1. Cache all document HTML and determine spine order
- self.doc_content = {}
- # Correctly get hrefs from spine items
- spine_docs = []
- for spine_item_tuple in book.spine:
- item_id = spine_item_tuple[0]
- item = book.get_item_with_id(item_id)
- if item:
- spine_docs.append(item.get_name()) # Use get_name() for href
- else:
- print(
- f"Warning: Spine item with id '{item_id}' not found in book items."
- )
-
- doc_order = {href: i for i, href in enumerate(spine_docs)}
-
- for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
- href = item.get_name()
- if href in doc_order: # Only process docs in spine
- try:
- html_content = item.get_content().decode("utf-8", errors="ignore")
- self.doc_content[href] = html_content
- except Exception:
- self.doc_content[href] = "" # Handle decoding errors
-
- # 2. Get all TOC entries with hrefs and determine their positions
- toc_entries_with_pos = []
-
- def find_position(doc_href, fragment_id):
- if doc_href not in self.doc_content:
- return -1
- html_content = self.doc_content[doc_href]
- if not fragment_id: # No fragment, position is 0
- return 0
-
- # Find position of fragment identifier (id= or name=)
- 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
- else:
- return -1 # Anchor not found by simple string search
-
- # Backtrack to the start of the tag '<'
- tag_start_pos = html_content.rfind("<", 0, pos)
- return (
- tag_start_pos if tag_start_pos != -1 else 0
- ) # Default to 0 if '<' not found
-
- def collect_toc_entries(entries):
- collected = []
- for entry in entries:
- href, title = None, "Unknown"
- children = []
- entry_obj = None # Store the original entry object
-
- if isinstance(entry, ebooklib.epub.Link):
- href, title = entry.href, entry.title or entry.href
- entry_obj = entry
- elif isinstance(entry, tuple) and len(entry) >= 1:
- section_or_link = entry[0]
- entry_obj = section_or_link
- if isinstance(section_or_link, ebooklib.epub.Section):
- title = section_or_link.title
- href = getattr(section_or_link, "href", None)
- elif isinstance(section_or_link, ebooklib.epub.Link):
- href, title = (
- section_or_link.href,
- section_or_link.title or section_or_link.href,
- )
-
- if len(entry) > 1 and isinstance(entry[1], list):
- children = entry[1]
-
- if href:
- base_href, fragment = (
- href.split("#", 1) if "#" in href else (href, None)
- )
- if (
- base_href in doc_order
- ): # Only consider entries pointing to spine documents
- position = find_position(base_href, fragment)
- if position != -1: # Only add if position is valid
- collected.append(
- {
- "href": href, # Use the original href from TOC as the key
- "title": title,
- "doc_href": base_href,
- "position": position,
- "doc_order": doc_order[base_href],
- }
- )
-
- if children:
- collected.extend(collect_toc_entries(children))
- return collected
-
- all_toc_entries = collect_toc_entries(self.book.toc)
-
- # Handle case where book has no TOC or empty TOC
- if not all_toc_entries:
- # Create a synthetic TOC entry for the first spine document
- if spine_docs:
- # Process all content as a single chapter
- all_content_html = ""
- for doc_href in spine_docs:
- all_content_html += self.doc_content.get(doc_href, "")
-
- if all_content_html:
- soup = BeautifulSoup(all_content_html, "html.parser")
- text = clean_text(soup.get_text()).strip()
-
- # Use the first spine document as the identifier
- first_doc = spine_docs[0]
- self.content_texts[first_doc] = text
- self.content_lengths[first_doc] = len(text)
-
- # Create a synthetic TOC entry for tree building
- self.book.toc = [(epub.Link(first_doc, "Main Content", first_doc),)]
- return
-
- # 3. Sort TOC entries globally
- all_toc_entries.sort(key=lambda x: (x["doc_order"], x["position"]))
-
- # 4. Slice content between sorted entries
- self.content_texts = {}
- self.content_lengths = {}
- num_entries = len(all_toc_entries)
-
- for i in range(num_entries):
- current_entry = all_toc_entries[i]
- current_href = current_entry["href"]
- 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 = ""
-
- # Find the start of the next TOC entry
- next_entry = all_toc_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:
- # Next entry is in the same document
- slice_html = current_doc_html[start_slice_pos:next_pos]
- else:
- # Next entry is in a different document
- # Take content from current position to end of current document
- slice_html = current_doc_html[start_slice_pos:]
- # Include content from intermediate documents in the spine
- current_doc_index = current_entry["doc_order"]
- next_doc_index = next_entry["doc_order"]
- for doc_idx in range(current_doc_index + 1, next_doc_index):
- intermediate_doc_href = spine_docs[doc_idx]
- slice_html += self.doc_content.get(intermediate_doc_href, "")
- # Add content from the beginning of the next document up to the next entry's position
- next_doc_html = self.doc_content.get(next_doc, "")
- slice_html += next_doc_html[:next_pos]
- else:
- # This is the last TOC entry
- # Take content from current position to end of current document
- slice_html = current_doc_html[start_slice_pos:]
- # Include content from all remaining documents in the spine
- current_doc_index = current_entry["doc_order"]
- for doc_idx in range(current_doc_index + 1, len(spine_docs)):
- intermediate_doc_href = spine_docs[doc_idx]
- slice_html += self.doc_content.get(intermediate_doc_href, "")
-
- # 5. Extract text and store
- slice_soup = BeautifulSoup(slice_html, "html.parser")
-
- # Remove sup and sub tags from the HTML before extracting text
- for tag in slice_soup.find_all(["sup", "sub"]):
- tag.decompose()
-
- text = clean_text(slice_soup.get_text()).strip()
- self.content_texts[current_href] = text # Store using the original TOC href
- self.content_lengths[current_href] = len(text)
-
- # 6. Handle content BEFORE the first TOC entry
- if all_toc_entries:
- first_entry = all_toc_entries[0]
- first_doc_href = first_entry["doc_href"]
- first_pos = first_entry["position"]
- first_doc_order = first_entry["doc_order"]
- prefix_html = ""
- # Include content from documents before the first entry's document
- for doc_idx in range(first_doc_order):
- intermediate_doc_href = spine_docs[doc_idx]
- prefix_html += self.doc_content.get(intermediate_doc_href, "")
- # Include content from the start of the first entry's document up to its position
- 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")
- # Remove sup and sub tags
- for tag in prefix_soup.find_all(["sup", "sub"]):
- tag.decompose()
- prefix_text = clean_text(prefix_soup.get_text()).strip()
-
- if prefix_text:
- # Create a new chapter for content before the first TOC entry
- # Use a synthetic href to avoid collision with real TOC entries
- prefix_chapter_href = "prefix_content_chapter"
- self.content_texts[prefix_chapter_href] = prefix_text
- self.content_lengths[prefix_chapter_href] = len(prefix_text)
-
- # Add a new entry to the TOC for the prefix content
- prefix_link = epub.Link(
- prefix_chapter_href, "Introduction", prefix_chapter_href
- )
- # Insert at beginning of TOC
- if isinstance(self.book.toc, list):
- self.book.toc.insert(0, (prefix_link,))
- else:
- self.book.toc = [(prefix_link,)] + (self.book.toc or [])
-
def _preprocess_pdf_content(self):
"""Pre-process all page contents from PDF document"""
for page_num in range(len(self.pdf_doc)):
@@ -395,16 +200,624 @@ class HandlerDialog(QDialog):
self.content_texts[page_id] = text
self.content_lengths[page_id] = calculate_text_length(text)
- def _build_tree(self):
- """Build tree based on file type"""
- if self.file_type == "epub":
- self._build_epub_tree()
- else:
- self._build_pdf_tree()
+ def _process_epub_content_spine_fallback(self):
+ """Fallback EPUB processing based purely on spine order."""
+ 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.")
- def _build_epub_tree(self):
- """Build the tree for EPUB files from TOC"""
+ # Cache content
+ 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 as e:
+ logging.error(f"Error decoding content for {href}: {e}")
+ self.doc_content[href] = ""
+
+ # Create a simple TOC based on spine order
+ synthetic_toc = []
+ 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")
+ # Remove sup and sub tags
+ for tag in soup.find_all(["sup", "sub"]):
+ tag.decompose()
+ text = clean_text(soup.get_text()).strip()
+ if text:
+ # Use doc_href as the identifier
+ self.content_texts[doc_href] = text
+ self.content_lengths[doc_href] = len(text)
+ # Create a synthetic TOC entry
+ title = f"Chapter {i+1}: {doc_href}"
+ # Try to get a better title from