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

or + h1 = soup.find("h1") + if h1 and h1.get_text(strip=True): + title = h1.get_text(strip=True) + else: + title_tag = soup.find("title") + if title_tag and title_tag.get_text(strip=True): + title = title_tag.get_text(strip=True) + + synthetic_toc.append( + (epub.Link(doc_href, title, doc_href), []) + ) # Wrap in tuple and empty list for compatibility + + # Replace book.toc with the synthetic one if it was empty or fallback was triggered + if not self.book.toc or not hasattr( + self, "processed_nav_structure" + ): # Check if nav processing failed + self.book.toc = synthetic_toc + logging.info(f"Generated synthetic TOC with {len(synthetic_toc)} entries.") + + def _process_epub_content_nav(self): + """ + Process EPUB content using ITEM_NAVIGATION (NAV HTML) or ITEM_NCX. + Globally orders navigation entries and slices content between them. + """ + logging.info( + "Attempting to process EPUB using navigation document (NAV/NCX)..." + ) + nav_item = None + nav_type = None + + # 1. Check ITEM_NAVIGATION for actual NAV HTML (.xhtml/.html) + nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) + if nav_items: + # Prefer files explicitly named 'nav.xhtml' or similar + preferred_nav = next( + ( + item + for item in nav_items + if "nav" in item.get_name().lower() + and item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if preferred_nav: + nav_item = preferred_nav + nav_type = "html" + logging.info(f"Found preferred NAV HTML item: {nav_item.get_name()}") + else: + # Check if any ITEM_NAVIGATION is actually HTML + html_nav = next( + ( + item + for item in nav_items + if item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if html_nav: + nav_item = html_nav + nav_type = "html" + logging.info( + f"Found NAV HTML item in ITEM_NAVIGATION: {html_nav.get_name()}" + ) + + # 2. If no NAV HTML found via ITEM_NAVIGATION, check if ITEM_NAVIGATION points to NCX + 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" + logging.info( + f"Found NCX item via ITEM_NAVIGATION: {ncx_in_nav.get_name()}" + ) + + # 3. If still no nav_item, check for ITEM_NCX directly + if not nav_item: + ncx_items = list(self.book.get_items_of_type(ebooklib.ITEM_NCX)) + if ncx_items: + nav_item = ncx_items[0] # Take the first one + nav_type = "ncx" + logging.info(f"Found NCX item via ITEM_NCX: {ncx_items[0].get_name()}") + + # 4. If no navigation item found by any method, trigger fallback + if not nav_item or not nav_type: + logging.warning( + "No suitable EPUB navigation document (NAV HTML or NCX) found. Falling back." + ) + raise ValueError("No navigation document found") # Trigger fallback + + # Determine parser based on the confirmed nav_type + parser_type = "html.parser" if nav_type == "html" else "xml" + logging.info(f"Using parser: '{parser_type}' for {nav_item.get_name()}") + try: + nav_content = nav_item.get_content().decode("utf-8", errors="ignore") + nav_soup = BeautifulSoup(nav_content, parser_type) + except Exception as e: + logging.error( + f"Failed to parse navigation content ({nav_item.get_name()}) using {parser_type}: {e}", + exc_info=True, + ) + raise ValueError( + f"Failed to parse navigation content: {e}" + ) # Trigger fallback + + # --- Rest of the processing logic --- + # 1. Cache all document HTML and determine spine order (no changes needed here) + 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.") + doc_order = {href: i for i, href in enumerate(spine_docs)} + # Add a mapping for unquoted (decoded) hrefs as well + doc_order_decoded = { + urllib.parse.unquote(href): i for href, i in doc_order.items() + } + + # Clear previous content/lengths before processing + 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: + 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] = "" + + # 2. Extract and order navigation entries globally + ordered_nav_entries = [] + + # Define find_position locally or ensure self._find_position_robust is used correctly + # Using self._find_position_robust is preferred as it's a method of the class + find_position_func = self._find_position_robust + + # Store the parsed structure for tree building later + self.processed_nav_structure = [] + + # Call the correct parsing function based on confirmed nav_type + parse_successful = False + if nav_type == "ncx": + nav_map = nav_soup.find("navMap") + if nav_map: + logging.info("Parsing NCX <navMap>...") + 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, + find_position_func, + ) + parse_successful = bool( + ordered_nav_entries + ) # Success if entries were added + else: + logging.warning("Could not find <navMap> in NCX file.") + elif nav_type == "html": + logging.info("Parsing NAV HTML...") + toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"}) + if not toc_nav: + # Fallback: look for any <nav> element containing an <ol> + all_navs = nav_soup.find_all("nav") + for nav in all_navs: + if nav.find("ol"): + toc_nav = nav + logging.info("Found fallback TOC structure in <nav> with <ol>.") + 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, + find_position_func, + ) + parse_successful = bool( + ordered_nav_entries + ) # Success if entries were added + else: + logging.warning("Found <nav> for TOC but no top-level <ol> inside.") + else: + logging.warning( + "Could not find TOC structure (<nav epub:type='toc'> or <nav><ol>) in NAV HTML." + ) + + # Handle case where parsing ran but found no valid entries OR parsing failed + if not parse_successful: + logging.warning( + "Navigation parsing completed but found no valid entries, or parsing failed. Falling back." + ) + raise ValueError("No valid navigation entries found after parsing") + + # Sort entries globally by document order and position within the document + ordered_nav_entries.sort(key=lambda x: (x["doc_order"], x["position"])) + logging.info(f"Sorted {len(ordered_nav_entries)} navigation entries.") + + # 3. Slice content ONLY between sorted TOC entries + 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"] + + # Always include all content from current position to next position, even if next_doc is before current_doc + if current_doc == next_doc: + slice_html = current_doc_html[start_slice_pos:next_pos] + else: + # Collect all content from current_doc (from start_slice_pos to end), + # then all intermediate docs (in spine order), + # then up to next_pos in next_doc (even if next_doc is before current_doc in spine) + 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: + for doc_idx in range(idx_current + 1, idx_next): + docs_between.append(spine_docs[doc_idx]) + elif idx_current > idx_next: + for doc_idx in range(idx_current + 1, len(spine_docs)): + docs_between.append(spine_docs[doc_idx]) + for doc_idx in range(0, idx_next): + docs_between.append(spine_docs[doc_idx]) + except Exception: + 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: + # Last TOC entry: include all content from current position to end of book + 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)): + intermediate_doc_href = spine_docs[doc_idx] + slice_html += self.doc_content.get(intermediate_doc_href, "") + except Exception: + pass + + if slice_html.strip(): + slice_soup = BeautifulSoup(slice_html, "html.parser") + # Add double newlines after <p> and <div> tags + for tag in slice_soup.find_all(["p", "div"]): + tag.append("\n\n") + 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] = len(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 + + # 4. Extract text and store using the original TOC entry src as the key + 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, "") + else: + logging.warning( + f"Document index {doc_idx} out of bounds for spine (length {len(spine_docs)})." + ) + + 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": [], + }, + ) + logging.info( + f"Added prefix content chapter '{prefix_chapter_src}'." + ) + + logging.info( + f"Finished processing EPUB navigation. Found {len(self.content_texts)} content sections linked to TOC." + ) + + def _parse_ncx_navpoint( + self, + nav_point, + ordered_entries, + doc_order, + doc_order_decoded, + tree_structure_list, + find_position_func, + ): + 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) + # Try both original and decoded hrefs + doc_key = None + if base_href in doc_order: + doc_key = base_href + doc_idx = doc_order[base_href] + elif urllib.parse.unquote(base_href) in doc_order: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order[doc_key] + elif base_href in doc_order_decoded: + doc_key = base_href + doc_idx = doc_order_decoded[base_href] + elif urllib.parse.unquote(base_href) in doc_order_decoded: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order_decoded[doc_key] + else: + logging.warning( + f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list." + ) + current_entry_node["has_content"] = False + doc_key = None + 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: + logging.warning(f"Navigation entry '{title}' has no 'src' attribute.") + current_entry_node["has_content"] = False + + child_navpoints = nav_point.find_all("navPoint", recursive=False) + if child_navpoints: + for child_np in child_navpoints: + # Pass find_position_func down recursively + 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 _parse_html_nav_li( + self, + li_element, + ordered_entries, + doc_order, + doc_order_decoded, + tree_structure_list, + find_position_func, + ): + link = li_element.find("a", recursive=False) + span_text = li_element.find("span", recursive=False) + title = "Untitled Section" + src = None + current_entry_node = {"children": []} + + if link and "href" in link.attrs: + src = link["href"] + title = link.get_text(strip=True) or title + if not title.strip() and span_text: + title = span_text.get_text(strip=True) or title + if not title.strip(): + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + elif span_text: + title = span_text.get_text(strip=True) or title + if not title.strip(): + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + else: + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + + current_entry_node["title"] = title + current_entry_node["src"] = src + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + # Try both original and decoded hrefs + doc_key = None + if base_href in doc_order: + doc_key = base_href + doc_idx = doc_order[base_href] + elif urllib.parse.unquote(base_href) in doc_order: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order[doc_key] + elif base_href in doc_order_decoded: + doc_key = base_href + doc_idx = doc_order_decoded[base_href] + elif urllib.parse.unquote(base_href) in doc_order_decoded: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order_decoded[doc_key] + else: + logging.warning( + f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list." + ) + current_entry_node["has_content"] = False + doc_key = None + 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 + + child_ol = li_element.find("ol", recursive=False) + if child_ol: + for child_li in child_ol.find_all("li", recursive=False): + # Pass find_position_func down recursively + self._parse_html_nav_li( + child_li, + 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 _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: + logging.debug( + f"Found position for id='{fragment_id}' in {doc_href} using BeautifulSoup: {pos}" + ) + return pos + except Exception as e: + logging.warning( + f"BeautifulSoup failed to find id='{fragment_id}' in {doc_href}: {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: + pos = match.start() + logging.debug( + f"Found position for id/name='{fragment_id}' in {doc_href} using regex: {pos}" + ) + return pos + + 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 + logging.debug( + f"Found position for id/name='{fragment_id}' in {doc_href} using string search: {final_pos}" + ) + return final_pos + + logging.warning( + f"Anchor '{fragment_id}' not found in {doc_href}. Defaulting to position 0." + ) + return 0 + + def _build_tree(self): self.treeWidget.clear() + info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) info_item.setData(0, Qt.UserRole, "info:bookinfo") info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable) @@ -412,76 +825,129 @@ class HandlerDialog(QDialog): font.setBold(True) info_item.setFont(0, font) - # Regular tree building - def build_tree(toc_entries, parent_item): - for entry in toc_entries: - href, title, children = None, "Unknown", [] - if isinstance(entry, ebooklib.epub.Link): - href, title = entry.href, entry.title or entry.href - elif isinstance(entry, tuple) and len(entry) >= 1: - section_or_link = entry[0] - 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] - else: - continue - - # Create tree item - item = QTreeWidgetItem(parent_item, [title]) - item.setData(0, Qt.UserRole, href) - - # Make item checkable if it has content - has_content = ( - href - and href in self.content_texts - and self.content_texts[href].strip() + if self.file_type == "epub": + if ( + hasattr(self, "processed_nav_structure") + and self.processed_nav_structure + ): + self._build_epub_tree_from_nav( + self.processed_nav_structure, self.treeWidget ) - if has_content or children: - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - is_checked = href and href in self.checked_chapters - item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked) + else: + logging.warning("Building EPUB tree using fallback book.toc.") + self._build_epub_tree_fallback(self.book.toc, self.treeWidget) + else: + self._build_pdf_tree() + + has_parents = False + iterator = QTreeWidgetItemIterator( + self.treeWidget, QTreeWidgetItemIterator.HasChildren + ) + if iterator.value(): + has_parents = True + self.treeWidget.setRootIsDecorated(has_parents) + + def _build_epub_tree_from_nav( + self, nav_nodes, parent_item, seen_content_hashes=None + ): + if seen_content_hashes is None: + seen_content_hashes = set() + for node in nav_nodes: + title = node.get("title", "Unknown") + src = node.get("src") + children = node.get("children", []) + + item = QTreeWidgetItem(parent_item, [title]) + item.setData(0, Qt.UserRole, src) + + is_empty = ( + src + and (src in self.content_texts) + and (not self.content_texts[src].strip()) + ) + is_duplicate = False + if src and src in self.content_texts and self.content_texts[src].strip(): + content_hash = hash(self.content_texts[src]) + if content_hash in seen_content_hashes: + is_duplicate = True else: - item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + seen_content_hashes.add(content_hash) - # Process children - if children: - build_tree(children, item) + if src and not is_empty and not is_duplicate: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + is_checked = src in self.checked_chapters + item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked) + elif is_duplicate: + # Mark as duplicate and remove checkbox + item.setText(0, f"{title} (Duplicate)") + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + elif children: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Unchecked) + else: + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) - build_tree(self.book.toc, self.treeWidget) + if children: + self._build_epub_tree_from_nav(children, item, seen_content_hashes) + + def _build_epub_tree_fallback(self, toc_entries, parent_item): + for entry in toc_entries: + href, title, children = None, "Unknown", [] + entry_obj = None + 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] + else: + continue + + item = QTreeWidgetItem(parent_item, [title]) + item.setData(0, Qt.UserRole, href) + + has_content = ( + href and href in self.content_texts and self.content_texts[href].strip() + ) + + if has_content or children: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + is_checked = href and href in self.checked_chapters + item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked) + else: + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + + if children: + self._build_epub_tree_fallback(children, item) def _build_pdf_tree(self): - """Build the tree for PDF files combining outline/bookmarks with pages""" - # Get outline and store if this PDF has bookmarks outline = self.pdf_doc.get_toc() self.has_pdf_bookmarks = bool(outline) if not outline: - # No bookmarks/outline available, create a simple page list self._build_pdf_pages_tree() return - # Process the outline to determine page ranges bookmark_pages = [] page_to_bookmark = {} next_page_boundaries = {} - # Track added pages to prevent duplicates added_pages = set() - # Extract page numbers from outline recursively def extract_page_numbers(entries): for entry in entries: - if ( - len(entry) >= 3 - ): # Valid outline entry has at least level, title, page + if len(entry) >= 3: _, title, page = entry[:3] - # Convert page reference to actual page number (0-based) page_num = ( page - 1 if isinstance(page, int) @@ -489,27 +955,21 @@ class HandlerDialog(QDialog): ) bookmark_pages.append((page_num, title)) - # Process children recursively if len(entry) > 3 and isinstance(entry[3], list): extract_page_numbers(entry[3]) extract_page_numbers(outline) bookmark_pages.sort() - # Determine page ranges for each bookmark for i, (page_num, title) in enumerate(bookmark_pages): if i < len(bookmark_pages) - 1: next_page_boundaries[page_num] = bookmark_pages[i + 1][0] page_to_bookmark[page_num] = title - # Helper function to build the tree structure recursively def build_outline_tree(entries, parent_item): for entry in entries: - if ( - len(entry) >= 3 - ): # Valid outline entry has at least level, title, page + if len(entry) >= 3: entry_level, title, page = entry[:3] - # Get actual page number (0-based) page_num = ( page - 1 if isinstance(page, int) @@ -517,7 +977,6 @@ class HandlerDialog(QDialog): ) page_id = f"page_{page_num+1}" - # Create bookmark item bookmark_item = QTreeWidgetItem(parent_item, [title]) bookmark_item.setData(0, Qt.UserRole, page_id) bookmark_item.setFlags( @@ -532,15 +991,10 @@ class HandlerDialog(QDialog): ), ) - # Mark this page as added added_pages.add(page_num) - # Add child pages that belong to this bookmark next_page = next_page_boundaries.get(page_num, len(self.pdf_doc)) - for sub_page_num in range( - page_num + 1, next_page - ): # Skip the bookmark page itself - # Skip if this page is a bookmark itself or already added as a child elsewhere + for sub_page_num in range(page_num + 1, next_page): if ( sub_page_num in page_to_bookmark or sub_page_num in added_pages @@ -550,7 +1004,6 @@ class HandlerDialog(QDialog): page_id = f"page_{sub_page_num+1}" page_title = f"Page {sub_page_num+1}" - # Try to get a better title from the first line of content page_text = self.content_texts.get(page_id, "").strip() if page_text: first_line = page_text.split("\n", 1)[0].strip() @@ -569,22 +1022,15 @@ class HandlerDialog(QDialog): ), ) - # Mark this page as added added_pages.add(sub_page_num) - # Process child bookmarks if any if len(entry) > 3 and isinstance(entry[3], list): build_outline_tree(entry[3], bookmark_item) - # Start building the tree from the outline build_outline_tree(outline, self.treeWidget) - # Add pages not covered by bookmarks - covered_pages = set( - added_pages - ) # Use our tracked pages to find uncategorized ones + covered_pages = set(added_pages) - # Add remaining pages as top-level items under "Other Pages" uncategorized_pages = [ i for i in range(len(self.pdf_doc)) if i not in covered_pages ] @@ -592,7 +1038,6 @@ class HandlerDialog(QDialog): self._add_other_pages(uncategorized_pages) def _build_pdf_pages_tree(self): - """Build a simple page list for PDFs without bookmarks""" pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"]) pages_item.setFlags(pages_item.flags() & ~Qt.ItemIsUserCheckable) font = pages_item.font(0) @@ -603,7 +1048,6 @@ class HandlerDialog(QDialog): page_id = f"page_{page_num+1}" page_title = f"Page {page_num+1}" - # Try to get a better title from the first line of content page_text = self.content_texts.get(page_id, "").strip() if page_text: first_line = page_text.split("\n", 1)[0].strip() @@ -618,7 +1062,6 @@ class HandlerDialog(QDialog): ) def _add_other_pages(self, uncategorized_pages): - """Add uncategorized pages to the tree""" other_pages = QTreeWidgetItem(self.treeWidget, ["Other Pages"]) other_pages.setFlags(other_pages.flags() & ~Qt.ItemIsUserCheckable) font = other_pages.font(0) @@ -629,7 +1072,6 @@ class HandlerDialog(QDialog): page_id = f"page_{page_num+1}" page_title = f"Page {page_num+1}" - # Try to get better title from first line page_text = self.content_texts.get(page_id, "").strip() if page_text: first_line = page_text.split("\n", 1)[0].strip() @@ -644,11 +1086,9 @@ class HandlerDialog(QDialog): ) def _are_provided_checks_relevant(self): - """Check if provided checks are relevant to this book""" if not self.checked_chapters: return False - # Collect all identifiers present in tree all_identifiers = set() iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -659,18 +1099,14 @@ class HandlerDialog(QDialog): all_identifiers.add(identifier) iterator += 1 - # Check for any intersection with provided chapters return bool(self.checked_chapters.intersection(all_identifiers)) def _setup_ui(self): - """Set up the user interface""" - # Add preview panel self.previewEdit = QTextEdit(self) self.previewEdit.setReadOnly(True) self.previewEdit.setMinimumWidth(300) self.previewEdit.setStyleSheet("QTextEdit { border: none; }") - # Create informative text label below preview self.previewInfoLabel = QLabel( '*Note: You can modify the content later using the "Edit" button in the input box or by accessing the temporary files directory through settings.', self, @@ -680,7 +1116,6 @@ class HandlerDialog(QDialog): "QLabel { color: #666; font-style: italic; }" ) - # Right panel layout (preview and info label) previewLayout = QVBoxLayout() previewLayout.setContentsMargins(0, 0, 0, 0) previewLayout.addWidget(self.previewEdit, 1) @@ -689,30 +1124,24 @@ class HandlerDialog(QDialog): rightWidget = QWidget() rightWidget.setLayout(previewLayout) - # Dialog buttons buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) - # Selection buttons item_type = "chapters" if self.file_type == "epub" else "pages" - # Auto-select button self.auto_select_btn = QPushButton(f"Auto-select {item_type}", self) self.auto_select_btn.clicked.connect(self.auto_select_chapters) self.auto_select_btn.setToolTip(f"Automatically select main {item_type}") - # Selection buttons layout buttons_layout = QVBoxLayout() buttons_layout.setContentsMargins(0, 0, 0, 0) buttons_layout.setSpacing(10) - # Row 1: Auto-select auto_select_layout = QHBoxLayout() auto_select_layout.addWidget(self.auto_select_btn) buttons_layout.addLayout(auto_select_layout) - # Row 2: Select/Deselect All select_layout = QHBoxLayout() self.select_all_btn = QPushButton("Select all", self) self.select_all_btn.clicked.connect(self.select_all_chapters) @@ -722,7 +1151,6 @@ class HandlerDialog(QDialog): select_layout.addWidget(self.deselect_all_btn) buttons_layout.addLayout(select_layout) - # Row 3: Parent selection parent_layout = QHBoxLayout() self.select_parents_btn = QPushButton("Select parents", self) self.select_parents_btn.clicked.connect(self.select_parent_chapters) @@ -732,7 +1160,6 @@ class HandlerDialog(QDialog): parent_layout.addWidget(self.deselect_parents_btn) buttons_layout.addLayout(parent_layout) - # Row 4: Expand/Collapse expand_layout = QHBoxLayout() self.expand_all_btn = QPushButton("Expand All", self) self.expand_all_btn.clicked.connect(self.treeWidget.expandAll) @@ -742,13 +1169,11 @@ class HandlerDialog(QDialog): expand_layout.addWidget(self.collapse_all_btn) buttons_layout.addLayout(expand_layout) - # Left panel layout leftLayout = QVBoxLayout() leftLayout.setContentsMargins(0, 0, 5, 0) leftLayout.addLayout(buttons_layout) leftLayout.addWidget(self.treeWidget) - # Save options checkboxes checkbox_text = ( "Save each chapter separately" if self.file_type == "epub" @@ -770,33 +1195,25 @@ class HandlerDialog(QDialog): leftLayout.addWidget(buttons) - # Create left panel widget leftWidget = QWidget() leftWidget.setLayout(leftLayout) - # Create splitter for left panel and preview self.splitter = QSplitter(Qt.Horizontal) self.splitter.addWidget(leftWidget) - self.splitter.addWidget( - rightWidget - ) # Now using rightWidget that includes preview and label + self.splitter.addWidget(rightWidget) self.splitter.setSizes([280, 420]) - # Set main layout mainLayout = QVBoxLayout(self) mainLayout.addWidget(self.splitter) self.setLayout(mainLayout) def _update_checkbox_states(self): - """Update checkboxes enabled states based on document type and selection""" - # Make sure checkboxes exist before trying to modify them if ( not hasattr(self, "save_chapters_checkbox") or not self.save_chapters_checkbox ): return - # For PDFs without bookmarks, always disable separate chapters option if ( self.file_type == "pdf" and hasattr(self, "has_pdf_bookmarks") @@ -806,11 +1223,9 @@ class HandlerDialog(QDialog): self.merge_chapters_checkbox.setEnabled(False) return - # Count checked items differently based on file type checked_count = 0 if self.file_type == "epub": - # For EPUB: Count all checked items iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -823,9 +1238,7 @@ class HandlerDialog(QDialog): break iterator += 1 - else: # PDF - # For PDF: Count distinct parent groups - # We need content from at least 2 different parents to enable "save separately" + else: parent_groups = set() iterator = QTreeWidgetItemIterator(self.treeWidget) @@ -835,30 +1248,24 @@ class HandlerDialog(QDialog): item.flags() & Qt.ItemIsUserCheckable and item.checkState(0) == Qt.Checked ): - # Get the parent (or the item itself if it's a top-level item) parent = item.parent() if parent and parent != self.treeWidget.invisibleRootItem(): - # Use memory address as a unique identifier since QTreeWidgetItem is not hashable parent_groups.add(id(parent)) else: - # Top-level items count as their own parent group parent_groups.add(id(item)) iterator += 1 checked_count = len(parent_groups) - # Enable save separately only if enough distinct groups are checked min_groups_required = 2 self.save_chapters_checkbox.setEnabled(checked_count >= min_groups_required) - # Enable merge only if save separately is enabled and checked self.merge_chapters_checkbox.setEnabled( self.save_chapters_checkbox.isEnabled() and self.save_chapters_checkbox.isChecked() ) def select_all_chapters(self): - """Select all chapters/pages""" self._block_signals = True iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -870,7 +1277,6 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def deselect_all_chapters(self): - """Deselect all chapters/pages""" self._block_signals = True iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -882,7 +1288,6 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def select_parent_chapters(self): - """Select only parent chapters/sections""" self._block_signals = True iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -894,7 +1299,6 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def deselect_parent_chapters(self): - """Deselect only parent chapters/sections""" self._block_signals = True iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -906,23 +1310,20 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def auto_select_chapters(self): - """Auto-select chapters/pages""" self._run_auto_check() def _run_auto_check(self): - """Run automatic content selection based on file type""" self._block_signals = True if self.file_type == "epub": self._run_epub_auto_check() - else: # PDF + else: self._run_pdf_auto_check() self._block_signals = False self._update_checked_set_from_tree() def _run_epub_auto_check(self): - """Auto-check logic for EPUB files""" iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -930,25 +1331,30 @@ class HandlerDialog(QDialog): iterator += 1 continue - href = item.data(0, Qt.UserRole) - lookup_href = href.split("#")[0] if href else None + src = item.data(0, Qt.UserRole) - # Check based on length (> 1000 chars) and parent status - if ( - lookup_href and self.content_lengths.get(lookup_href, 0) > 1000 - ) or item.childCount() > 0: + has_significant_content = src and self.content_lengths.get(src, 0) > 1000 + is_parent = item.childCount() > 0 + + if has_significant_content or is_parent: item.setCheckState(0, Qt.Checked) - # Check children of parents - if item.childCount() > 0: + if is_parent: for i in range(item.childCount()): child = item.child(i) if child.flags() & Qt.ItemIsUserCheckable: - child.setCheckState(0, Qt.Checked) + child_src = child.data(0, Qt.UserRole) + child_has_content = ( + child_src and self.content_lengths.get(child_src, 0) > 0 + ) + child_is_parent = child.childCount() > 0 + if child_has_content or child_is_parent: + child.setCheckState(0, Qt.Checked) + else: + item.setCheckState(0, Qt.Unchecked) + iterator += 1 def _run_pdf_auto_check(self): - """Auto-check logic for PDF files""" - # If there are no bookmarks, just check all pages if hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks: iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -958,7 +1364,6 @@ class HandlerDialog(QDialog): iterator += 1 return - # For PDFs with bookmarks, select all bookmark items and non-empty pages iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -968,7 +1373,6 @@ class HandlerDialog(QDialog): identifier = item.data(0, Qt.UserRole) - # Always select bookmark items or non-empty pages if not identifier: iterator += 1 continue @@ -982,7 +1386,6 @@ class HandlerDialog(QDialog): iterator += 1 def _update_checked_set_from_tree(self): - """Update the internal set of checked items""" self.checked_chapters.clear() iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -992,18 +1395,15 @@ class HandlerDialog(QDialog): if identifier: self.checked_chapters.add(identifier) iterator += 1 - # Only update checkbox states if they exist if hasattr(self, "save_chapters_checkbox") and self.save_chapters_checkbox: self._update_checkbox_states() def handle_item_check(self, item): - """Handle item check/uncheck by updating children""" if self._block_signals: return self._block_signals = True - # Update children recursively if item.flags() & Qt.ItemIsUserCheckable: for i in range(item.childCount()): child = item.child(i) @@ -1014,49 +1414,37 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def handle_item_double_click(self, item, column=0): - """Toggle check state when a non-parent item is double-clicked on the text, not the checkbox""" - # Only toggle items that are checkable and don't have children if item.flags() & Qt.ItemIsUserCheckable and item.childCount() == 0: - # Get the rectangle of the checkbox rect = self.treeWidget.visualItemRect(item) - checkbox_width = 20 # Approximate width of the checkbox + checkbox_width = 20 - # Get current mouse position mouse_pos = self.treeWidget.mapFromGlobal(self.treeWidget.cursor().pos()) - # Only toggle if click position is not on the checkbox if mouse_pos.x() > rect.x() + checkbox_width: - # Toggle the check state new_state = ( Qt.Unchecked if item.checkState(0) == Qt.Checked else Qt.Checked ) item.setCheckState(0, new_state) def update_preview(self, current): - """Update the preview panel with selected item content""" if not current: self.previewEdit.clear() return identifier = current.data(0, Qt.UserRole) - # Special case for the Information item if identifier == "info:bookinfo": self._display_book_info() return - # Get content based on file type text = None if self.file_type == "epub": - # For EPUB, always use the exact href from the TOC text = self.content_texts.get(identifier) - else: # PDF + else: text = self.content_texts.get(identifier) - # Display content or placeholder text - never remove titles if text is None: title = current.text(0) - # Add title to preview even if no content self.previewEdit.setPlainText( f"{title}\n\n(No content available for this item)" ) @@ -1067,18 +1455,15 @@ class HandlerDialog(QDialog): self.previewEdit.setPlainText(text) def _display_book_info(self): - """Display book metadata and cover image in the preview panel""" self.previewEdit.clear() html_content = "<html><body style='font-family: Arial, sans-serif;'>" - # Add cover image if available if self.book_metadata["cover_image"]: try: image_data = base64.b64encode(self.book_metadata["cover_image"]).decode( "utf-8" ) - # Determine image type image_type = "jpeg" if self.book_metadata["cover_image"].startswith(b"\x89PNG"): image_type = "png" @@ -1095,7 +1480,6 @@ class HandlerDialog(QDialog): except Exception as e: html_content += f"<p>Error displaying cover image: {str(e)}</p>" - # Add title, authors, publisher if self.book_metadata["title"]: html_content += ( f"<h2 style='text-align: center;'>{self.book_metadata['title']}</h2>" @@ -1110,12 +1494,10 @@ class HandlerDialog(QDialog): html_content += "<hr/>" - # Add description if self.book_metadata["description"]: desc = re.sub(r"<[^>]+>", "", self.book_metadata["description"]) html_content += f"<h3>Description:</h3><p>{desc}</p>" - # Add file type and page count for PDFs if self.file_type == "pdf": page_count = len(self.pdf_doc) if self.pdf_doc else 0 html_content += f"<p>File type: PDF<br>Page count: {page_count}</p>" @@ -1124,7 +1506,6 @@ class HandlerDialog(QDialog): self.previewEdit.setHtml(html_content) def _extract_book_metadata(self): - """Extract book metadata""" metadata = { "title": None, "authors": [], @@ -1134,7 +1515,6 @@ class HandlerDialog(QDialog): } if self.file_type == "epub": - # Extract EPUB metadata title_items = self.book.get_metadata("DC", "title") if title_items: metadata["title"] = title_items[0][0] @@ -1151,7 +1531,6 @@ class HandlerDialog(QDialog): if publisher_items: metadata["publisher"] = publisher_items[0][0] - # Try to find cover image for item in self.book.get_items_of_type(ebooklib.ITEM_COVER): metadata["cover_image"] = item.get_content() break @@ -1161,8 +1540,7 @@ class HandlerDialog(QDialog): if "cover" in item.get_name().lower(): metadata["cover_image"] = item.get_content() break - else: # PDF - # Extract PDF metadata + else: pdf_info = self.pdf_doc.metadata if pdf_info: metadata["title"] = pdf_info.get("title", None) @@ -1182,7 +1560,6 @@ class HandlerDialog(QDialog): metadata["publisher"] = pdf_info.get("creator", None) - # Try to get cover image from first page if len(self.pdf_doc) > 0: try: pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2)) @@ -1193,54 +1570,52 @@ class HandlerDialog(QDialog): return metadata def get_selected_text(self): - """Get selected text and checked identifiers based on file type""" if self.file_type == "epub": return self._get_epub_selected_text() - else: # PDF + else: return self._get_pdf_selected_text() def _get_epub_selected_text(self): - """Get selected text from EPUB content""" - all_checked_hrefs = set() - chapter_titles = [] + all_checked_identifiers = set() + chapter_texts = [] - # Collect all checked hrefs in tree order to preserve chapter sequence + item_order_counter = 0 ordered_checked_items = [] + iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() + item_order_counter += 1 if item.checkState(0) == Qt.Checked: - href = item.data(0, Qt.UserRole) - if href and href != "info:bookinfo": - all_checked_hrefs.add(href) - ordered_checked_items.append((item, href)) + identifier = item.data(0, Qt.UserRole) + if identifier and identifier != "info:bookinfo": + all_checked_identifiers.add(identifier) + ordered_checked_items.append((item_order_counter, item, identifier)) iterator += 1 - # Process checked items in order - for item, href in ordered_checked_items: - # Always use the exact href (including fragment) from the TOC - text = self.content_texts.get(href) + ordered_checked_items.sort(key=lambda x: x[0]) + + for order, item, identifier in ordered_checked_items: + text = self.content_texts.get(identifier) if text and text.strip(): title = item.text(0) - title = re.sub(r"^\s*-\s*", "", title).strip() + title = re.sub(r"^\s*[-–—]\s*", "", title).strip() marker = f"<<CHAPTER_MARKER:{title}>>" - chapter_titles.append((title, marker + "\n" + text)) + chapter_texts.append(marker + "\n" + text) - return "\n\n".join([t[1] for t in chapter_titles]), all_checked_hrefs + full_text = "\n\n".join(chapter_texts) + return full_text, all_checked_identifiers def _get_pdf_selected_text(self): - """Get selected text from PDF content""" all_checked_identifiers = set() included_text_ids = set() section_titles = [] all_content = [] - # Check if PDF has no bookmarks pdf_has_no_bookmarks = ( hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks ) - # Collect all checked identifiers iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -1250,7 +1625,6 @@ class HandlerDialog(QDialog): all_checked_identifiers.add(identifier) iterator += 1 - # For PDFs without bookmarks, collect all content without chapter markers if pdf_has_no_bookmarks: sorted_page_ids = sorted( [id for id in all_checked_identifiers if id.startswith("page_")], @@ -1264,8 +1638,6 @@ class HandlerDialog(QDialog): included_text_ids.add(page_id) return "\n\n".join(all_content), all_checked_identifiers - # For PDFs with bookmarks, process content with parent-child relationships - # If only child pages are selected (not parent), use parent's name as chapter marker at first selected child iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -1273,7 +1645,6 @@ class HandlerDialog(QDialog): parent_checked = item.checkState(0) == Qt.Checked parent_id = item.data(0, Qt.UserRole) parent_title = item.text(0) - # Gather checked children checked_children = [] for i in range(item.childCount()): child = item.child(i) @@ -1284,7 +1655,6 @@ class HandlerDialog(QDialog): and child_id not in included_text_ids ): checked_children.append((child, child_id)) - # If parent is checked, use old logic (parent marker, all content) if parent_checked and parent_id and parent_id not in included_text_ids: combined_text = self.content_texts.get(parent_id, "") for child, child_id in checked_children: @@ -1297,7 +1667,6 @@ class HandlerDialog(QDialog): marker = f"<<CHAPTER_MARKER:{title}>>" section_titles.append((title, marker + "\n" + combined_text)) included_text_ids.add(parent_id) - # If only children are checked, use parent's name as marker at first child elif not parent_checked and checked_children: title = re.sub(r"^\s*-\s*", "", parent_title).strip() marker = f"<<CHAPTER_MARKER:{title}>>" @@ -1328,18 +1697,15 @@ class HandlerDialog(QDialog): return "\n\n".join([t[1] for t in section_titles]), all_checked_identifiers def on_save_chapters_changed(self, state): - """Update the save_chapters_separately flag""" self.save_chapters_separately = bool(state) self.merge_chapters_checkbox.setEnabled(self.save_chapters_separately) HandlerDialog._save_chapters_separately = self.save_chapters_separately def on_merge_chapters_changed(self, state): - """Update the merge_chapters_at_end flag""" self.merge_chapters_at_end = bool(state) HandlerDialog._merge_chapters_at_end = self.merge_chapters_at_end def get_save_chapters_separately(self): - """Return whether to save chapters separately""" return ( self.save_chapters_separately if self.save_chapters_checkbox.isEnabled() @@ -1347,11 +1713,9 @@ class HandlerDialog(QDialog): ) def get_merge_chapters_at_end(self): - """Return whether to merge chapters at the end""" return self.merge_chapters_at_end def on_tree_context_menu(self, pos): - """Handle context menu on tree items""" item = self.treeWidget.itemAt(pos) if ( not item @@ -1376,7 +1740,6 @@ class HandlerDialog(QDialog): menu.exec_(self.treeWidget.mapToGlobal(pos)) def closeEvent(self, event): - """Clean up resources when the dialog is closed""" if self.pdf_doc is not None: self.pdf_doc.close() event.accept() diff --git a/abogen/gui.py b/abogen/gui.py index a0c7ff6..3f7ceec 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -1445,10 +1445,10 @@ class abogen(QWidget): # stop loading animation and restore icon on error if error: self.loading_movie.stop() - self.btn_preview.setIcon(self.play_icon) self._show_error_message_box( "Loading Error", f"Error loading numpy or KPipeline: {error}" ) + self.btn_preview.setIcon(self.play_icon) self.btn_preview.setEnabled(True) self.btn_preview.setToolTip("Preview selected voice") self.voice_combo.setEnabled(True) @@ -1491,12 +1491,13 @@ class abogen(QWidget): temp_wav = self.preview_thread.temp_wav if not temp_wav: self.loading_movie.stop() - self.btn_preview.setIcon(self.play_icon) + self._show_error_message_box( "Preview Error", "Preview error: No audio generated." ) - self.btn_preview.setEnabled(True) + self.btn_preview.setIcon(self.play_icon) self.btn_preview.setToolTip("Preview selected voice") + self.btn_preview.setEnabled(True) self.voice_combo.setEnabled(True) self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button self.btn_start.setEnabled(True) diff --git a/demo/abogen.png b/demo/abogen.png index 16adbaf..b11c5dd 100644 Binary files a/demo/abogen.png and b/demo/abogen.png differ diff --git a/pyproject.toml b/pyproject.toml index 7f626a6..3f5ff16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "ko dependencies = [ "PyQt5>=5.15.11", "kokoro>=0.9.4", - "ebooklib>=0.18", + "ebooklib>=0.19", "beautifulsoup4>=4.13.4", "PyMuPDF>=1.25.5", "platformdirs>=4.3.7",