diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 67ac0cd..f4e7c43 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -32,7 +32,12 @@ from PyQt6.QtCore import ( QPoint, QRect, ) -from abogen.utils import clean_text, calculate_text_length, detect_encoding, get_resource_path +from abogen.utils import ( + clean_text, + calculate_text_length, + detect_encoding, + get_resource_path, +) import os import logging # Add logging import urllib.parse @@ -50,7 +55,7 @@ class HandlerDialog(QDialog): _save_chapters_separately = False _merge_chapters_at_end = True _save_as_project = False # New class variable for save_as_project option - + # Cache for processed book content to avoid reprocessing # Key: (book_path, modification_time, file_type) # Value: dict with content_texts, content_lengths, doc_content (for epub), markdown_toc (for markdown) @@ -58,6 +63,7 @@ class HandlerDialog(QDialog): class _LoaderThread(QThread): """Minimal QThread that runs a callable and emits an error string on exception.""" + error = pyqtSignal(str) def __init__(self, target_callable): @@ -77,7 +83,9 @@ class HandlerDialog(QDialog): cls._content_cache.clear() logging.info("Cleared all content cache") else: - keys_to_remove = [key for key in cls._content_cache.keys() if key[0] == book_path] + keys_to_remove = [ + key for key in cls._content_cache.keys() if key[0] == book_path + ] for key in keys_to_remove: del cls._content_cache[key] if keys_to_remove: @@ -102,12 +110,14 @@ class HandlerDialog(QDialog): # Set window title based on file type and book name item_type = "Chapters" if self.file_type in ["epub", "markdown"] else "Pages" - self.setWindowTitle(f'Select {item_type} - {book_name}') + self.setWindowTitle(f"Select {item_type} - {book_name}") self.resize(1200, 900) self._block_signals = False # Flag to prevent recursive signals # Configure window: remove help button and allow resizing self.setWindowFlags( - Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowMaximizeButtonHint + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint ) self.setWindowModality(Qt.WindowModality.NonModal) # Initialize save chapters flags from class variables @@ -199,7 +209,7 @@ class HandlerDialog(QDialog): # Create a centered loading overlay and show it while background load runs self._create_loading_overlay() # Hide the main UI so only the overlay is visible initially - if getattr(self, 'splitter', None) is not None: + if getattr(self, "splitter", None) is not None: self.splitter.setVisible(False) self._show_loading_overlay("Loading...") @@ -214,8 +224,6 @@ class HandlerDialog(QDialog): break self.treeWidget.setRootIsDecorated(has_parents) - - def _create_loading_overlay(self): """Create a centered loading indicator with a GIF on the left and text on the right. @@ -277,10 +285,10 @@ class HandlerDialog(QDialog): self._loading_movie = None def _show_loading_overlay(self, text: str): - container = getattr(self, '_loading_container', None) - text_lbl = getattr(self, '_loading_text_label', None) - movie = getattr(self, '_loading_movie', None) - gif_lbl = getattr(self, '_loading_gif_label', None) + container = getattr(self, "_loading_container", None) + text_lbl = getattr(self, "_loading_text_label", None) + movie = getattr(self, "_loading_movie", None) + gif_lbl = getattr(self, "_loading_gif_label", None) if container is None or text_lbl is None: return text_lbl.setText(text) @@ -293,8 +301,8 @@ class HandlerDialog(QDialog): container.setVisible(True) def _hide_loading_overlay(self): - container = getattr(self, '_loading_container', None) - movie = getattr(self, '_loading_movie', None) + container = getattr(self, "_loading_container", None) + movie = getattr(self, "_loading_movie", None) if container is None: return if movie is not None: @@ -304,7 +312,6 @@ class HandlerDialog(QDialog): pass container.setVisible(False) - def _start_background_load(self): """Start a QThread that runs the preprocessing in background.""" # Start a minimal QThread which executes _preprocess_content @@ -317,9 +324,9 @@ class HandlerDialog(QDialog): def _on_load_error(self, err_msg): logging.error(f"Error loading book in background: {err_msg}") - if getattr(self, 'previewEdit', None) is not None: + if getattr(self, "previewEdit", None) is not None: self.previewEdit.setPlainText(f"Error loading book: {err_msg}") - if getattr(self, 'splitter', None) is not None: + if getattr(self, "splitter", None) is not None: self.splitter.setVisible(True) self._hide_loading_overlay() @@ -337,7 +344,9 @@ class HandlerDialog(QDialog): # Connect signals (after tree exists) self.treeWidget.currentItemChanged.connect(self.update_preview) self.treeWidget.itemChanged.connect(self.handle_item_check) - self.treeWidget.itemChanged.connect(lambda _: self._update_checkbox_states()) + self.treeWidget.itemChanged.connect( + lambda _: self._update_checkbox_states() + ) self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click) # Expand and select first item @@ -356,7 +365,7 @@ class HandlerDialog(QDialog): except Exception as e: logging.error(f"Error finalizing book load: {e}") # Show the main UI and hide loading text - if getattr(self, 'splitter', None) is not None: + if getattr(self, "splitter", None) is not None: self.splitter.setVisible(True) self._hide_loading_overlay() @@ -367,21 +376,21 @@ class HandlerDialog(QDialog): mod_time = os.path.getmtime(self.book_path) except Exception: mod_time = 0 - + cache_key = (self.book_path, mod_time, self.file_type) - + # Check if content is already cached if cache_key in HandlerDialog._content_cache: cached_data = HandlerDialog._content_cache[cache_key] - self.content_texts = cached_data['content_texts'] - self.content_lengths = cached_data['content_lengths'] - if 'doc_content' in cached_data: - self.doc_content = cached_data['doc_content'] - if 'markdown_toc' in cached_data: - self.markdown_toc = cached_data['markdown_toc'] + self.content_texts = cached_data["content_texts"] + self.content_lengths = cached_data["content_lengths"] + if "doc_content" in cached_data: + self.doc_content = cached_data["doc_content"] + if "markdown_toc" in cached_data: + self.markdown_toc = cached_data["markdown_toc"] logging.info(f"Using cached content for {os.path.basename(self.book_path)}") return - + # Process content if not cached if self.file_type == "epub": try: @@ -397,17 +406,17 @@ class HandlerDialog(QDialog): self._preprocess_markdown_content() else: self._preprocess_pdf_content() - + # Cache the processed content cache_data = { - 'content_texts': self.content_texts, - 'content_lengths': self.content_lengths, + "content_texts": self.content_texts, + "content_lengths": self.content_lengths, } - if hasattr(self, 'doc_content'): - cache_data['doc_content'] = self.doc_content - if hasattr(self, 'markdown_toc'): - cache_data['markdown_toc'] = self.markdown_toc - + if hasattr(self, "doc_content"): + cache_data["doc_content"] = self.doc_content + if hasattr(self, "markdown_toc"): + cache_data["markdown_toc"] = self.markdown_toc + HandlerDialog._content_cache[cache_key] = cache_data logging.info(f"Cached content for {os.path.basename(self.book_path)}") @@ -432,7 +441,6 @@ class HandlerDialog(QDialog): page_id = f"page_{page_num + 1}" self.content_texts[page_id] = text self.content_lengths[page_id] = calculate_text_length(text) - def _preprocess_markdown_content(self): if not self.markdown_text: @@ -441,14 +449,14 @@ class HandlerDialog(QDialog): # Generate TOC from the original (dedented) markdown BEFORE cleaning, # so header ids/anchors are preserved for reliable position detection. original_text = textwrap.dedent(self.markdown_text) - md = markdown.Markdown(extensions=['toc', 'fenced_code']) + md = markdown.Markdown(extensions=["toc", "fenced_code"]) html = md.convert(original_text) self.markdown_toc = md.toc_tokens # Use cleaned text for stored content/length calculations cleaned_full_text = clean_text(original_text) - soup = BeautifulSoup(html, 'html.parser') + soup = BeautifulSoup(html, "html.parser") self.content_texts = {} self.content_lengths = {} @@ -459,35 +467,39 @@ class HandlerDialog(QDialog): return all_headers = [] + def flatten_toc(toc_list): for header in toc_list: all_headers.append(header) - if header.get('children'): - flatten_toc(header['children']) + if header.get("children"): + flatten_toc(header["children"]) + flatten_toc(self.markdown_toc) header_positions = [] for header in all_headers: - header_id = header['id'] + header_id = header["id"] 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': header['name'] - }) - header_positions.sort(key=lambda x: x['start']) + tag_start = html.rfind("<", 0, pos) + header_positions.append( + {"id": header_id, "start": tag_start, "name": header["name"]} + ) + 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) + 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}) + section_soup = BeautifulSoup(section_html, "html.parser") + header_tag = section_soup.find(attrs={"id": header_id}) if header_tag: header_tag.decompose() # Clean section text for storage/lengths @@ -533,7 +545,7 @@ class HandlerDialog(QDialog): html_content = self.doc_content.get(doc_href, "") if html_content: soup = BeautifulSoup(html_content, "html.parser") - + # Handle ordered lists by prepending numbers to list items for ol in soup.find_all("ol"): # Get start attribute or default to 1 @@ -545,11 +557,11 @@ class HandlerDialog(QDialog): li.string.replace_with(number_text + li.string) else: li.insert(0, NavigableString(number_text)) - + # Remove sup and sub tags 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 @@ -569,7 +581,7 @@ class HandlerDialog(QDialog): # 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" + 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.") @@ -594,7 +606,7 @@ class HandlerDialog(QDialog): item for item in nav_items if "nav" in item.get_name().lower() - and item.get_name().lower().endswith((".xhtml", ".html")) + and item.get_name().lower().endswith((".xhtml", ".html")) ), None, ) @@ -707,8 +719,8 @@ class HandlerDialog(QDialog): 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"]) + 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") @@ -856,7 +868,7 @@ class HandlerDialog(QDialog): # Add line breaks after paragraphs and divs for tag in slice_soup.find_all(["p", "div"]): tag.append("\n\n") - + # Handle ordered lists by prepending numbers to list items for ol in slice_soup.find_all("ol"): # Get start attribute or default to 1 @@ -868,11 +880,11 @@ class HandlerDialog(QDialog): li.string.replace_with(number_text + li.string) else: li.insert(0, NavigableString(number_text)) - + # Remove sup and sub tags that might contain footnotes 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 @@ -948,13 +960,13 @@ class HandlerDialog(QDialog): return None, None def _parse_ncx_navpoint( - self, - nav_point, - ordered_entries, - doc_order, - doc_order_decoded, - tree_structure_list, - find_position_func, + 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") @@ -1006,19 +1018,19 @@ class HandlerDialog(QDialog): ) if title and ( - current_entry_node.get("has_content", False) - or current_entry_node["children"] + 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, + 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) @@ -1166,8 +1178,8 @@ class HandlerDialog(QDialog): if self.file_type == "epub": if ( - hasattr(self, "processed_nav_structure") - and self.processed_nav_structure + hasattr(self, "processed_nav_structure") + and self.processed_nav_structure ): self._build_epub_tree_from_nav( self.processed_nav_structure, self.treeWidget @@ -1195,7 +1207,7 @@ class HandlerDialog(QDialog): self._update_item_checkbox_state(item) def _build_epub_tree_from_nav( - self, nav_nodes, parent_item, seen_content_hashes=None + self, nav_nodes, parent_item, seen_content_hashes=None ): if seen_content_hashes is None: seen_content_hashes = set() @@ -1208,9 +1220,9 @@ class HandlerDialog(QDialog): item.setData(0, Qt.ItemDataRole.UserRole, src) is_empty = ( - src - and (src in self.content_texts) - and (not self.content_texts[src].strip()) + 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(): @@ -1223,7 +1235,9 @@ class HandlerDialog(QDialog): if src and not is_empty and not is_duplicate: item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) is_checked = src in self.checked_chapters - item.setCheckState(0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked) + item.setCheckState( + 0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked + ) elif is_duplicate: # Mark as duplicate and remove checkbox item.setText(0, f"{title} (Duplicate)") @@ -1265,13 +1279,15 @@ class HandlerDialog(QDialog): item.setData(0, Qt.ItemDataRole.UserRole, href) has_content = ( - href and href in self.content_texts and self.content_texts[href].strip() + href and href in self.content_texts and self.content_texts[href].strip() ) if has_content or children: item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) is_checked = href and href in self.checked_chapters - item.setCheckState(0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked) + item.setCheckState( + 0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked + ) else: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) @@ -1358,8 +1374,8 @@ class HandlerDialog(QDialog): next_page = next_page_boundaries.get(page_num, len(self.pdf_doc)) 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 + sub_page_num in page_to_bookmark + or sub_page_num in added_pages ): continue @@ -1420,7 +1436,12 @@ class HandlerDialog(QDialog): if self.content_lengths.get(page_id, 0) > 0: page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) page_item.setCheckState( - 0, Qt.CheckState.Checked if page_id in self.checked_chapters else Qt.CheckState.Unchecked + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), ) else: page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) @@ -1440,31 +1461,45 @@ class HandlerDialog(QDialog): if self.content_lengths.get(chapter_id, 0) > 0: item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) is_checked = chapter_id in self.checked_chapters - item.setCheckState(0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked) + item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if is_checked + else Qt.CheckState.Unchecked + ), + ) else: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) return def build_from_toc(toc_list, parent_item): for header in toc_list: - title = header['name'] - chapter_id = header['id'] + title = header["name"] + chapter_id = header["id"] item = QTreeWidgetItem(parent_item, [title]) item.setData(0, Qt.ItemDataRole.UserRole, chapter_id) has_content = self.content_lengths.get(chapter_id, 0) > 0 - has_children = bool(header.get('children')) + has_children = bool(header.get("children")) if has_content or has_children: item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) is_checked = chapter_id in self.checked_chapters - item.setCheckState(0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked) + item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if is_checked + else Qt.CheckState.Unchecked + ), + ) else: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) if has_children: - build_from_toc(header['children'], item) + build_from_toc(header["children"], item) build_from_toc(self.markdown_toc, self.treeWidget) @@ -1491,7 +1526,12 @@ class HandlerDialog(QDialog): if self.content_lengths.get(page_id, 0) > 0: page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) page_item.setCheckState( - 0, Qt.CheckState.Checked if page_id in self.checked_chapters else Qt.CheckState.Unchecked + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), ) else: page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) @@ -1535,7 +1575,10 @@ class HandlerDialog(QDialog): rightWidget = QWidget() rightWidget.setLayout(previewLayout) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) @@ -1632,15 +1675,15 @@ class HandlerDialog(QDialog): def _update_checkbox_states(self): if ( - not hasattr(self, "save_chapters_checkbox") - or not self.save_chapters_checkbox + not hasattr(self, "save_chapters_checkbox") + or not self.save_chapters_checkbox ): return if ( - self.file_type == "pdf" - and hasattr(self, "has_pdf_bookmarks") - and not self.has_pdf_bookmarks + self.file_type == "pdf" + and hasattr(self, "has_pdf_bookmarks") + and not self.has_pdf_bookmarks ): self.save_chapters_checkbox.setEnabled(False) self.merge_chapters_checkbox.setEnabled(False) @@ -1653,8 +1696,8 @@ class HandlerDialog(QDialog): while iterator.value(): item = iterator.value() if ( - item.flags() & Qt.ItemFlag.ItemIsUserCheckable - and item.checkState(0) == Qt.CheckState.Checked + item.flags() & Qt.ItemFlag.ItemIsUserCheckable + and item.checkState(0) == Qt.CheckState.Checked ): checked_count += 1 if checked_count >= 2: @@ -1668,8 +1711,8 @@ class HandlerDialog(QDialog): while iterator.value(): item = iterator.value() if ( - item.flags() & Qt.ItemFlag.ItemIsUserCheckable - and item.checkState(0) == Qt.CheckState.Checked + item.flags() & Qt.ItemFlag.ItemIsUserCheckable + and item.checkState(0) == Qt.CheckState.Checked ): parent = item.parent() if parent and parent != self.treeWidget.invisibleRootItem(): @@ -1769,7 +1812,7 @@ class HandlerDialog(QDialog): if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: child_src = child.data(0, Qt.ItemDataRole.UserRole) child_has_content = ( - child_src and self.content_lengths.get(child_src, 0) > 0 + 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: @@ -1791,7 +1834,9 @@ class HandlerDialog(QDialog): identifier = item.data(0, Qt.ItemDataRole.UserRole) # Select chapters with content > 500 characters or parent items - has_significant_content = identifier and self.content_lengths.get(identifier, 0) > 500 + has_significant_content = ( + identifier and self.content_lengths.get(identifier, 0) > 500 + ) is_parent = item.childCount() > 0 if has_significant_content or is_parent: @@ -1803,7 +1848,8 @@ class HandlerDialog(QDialog): if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: child_identifier = child.data(0, Qt.ItemDataRole.UserRole) child_has_content = ( - child_identifier and self.content_lengths.get(child_identifier, 0) > 0 + child_identifier + and self.content_lengths.get(child_identifier, 0) > 0 ) child_is_parent = child.childCount() > 0 if child_has_content or child_is_parent: @@ -1837,8 +1883,8 @@ class HandlerDialog(QDialog): continue if ( - not identifier.startswith("page_") - or self.content_lengths.get(identifier, 0) > 0 + not identifier.startswith("page_") + or self.content_lengths.get(identifier, 0) > 0 ): item.setCheckState(0, Qt.CheckState.Checked) @@ -1881,7 +1927,9 @@ class HandlerDialog(QDialog): if mouse_pos.x() > rect.x() + checkbox_width: new_state = ( - Qt.CheckState.Unchecked if item.checkState(0) == Qt.CheckState.Checked else Qt.CheckState.Checked + Qt.CheckState.Unchecked + if item.checkState(0) == Qt.CheckState.Checked + else Qt.CheckState.Checked ) item.setCheckState(0, new_state) @@ -1949,7 +1997,7 @@ class HandlerDialog(QDialog): html_content += f"

By {authors_text}

" if self.book_metadata["publisher"] or self.book_metadata.get( - "publication_year" + "publication_year" ): pub_info = [] if self.book_metadata["publisher"]: @@ -2039,27 +2087,49 @@ class HandlerDialog(QDialog): # Extract metadata from markdown frontmatter or first heading if self.markdown_text: # Try to extract YAML frontmatter - frontmatter_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', self.markdown_text, re.DOTALL) + frontmatter_match = re.match( + r"^---\s*\n(.*?)\n---\s*\n", self.markdown_text, re.DOTALL + ) if frontmatter_match: try: frontmatter = frontmatter_match.group(1) # Simple YAML-like parsing for common fields - title_match = re.search(r'^title:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE) + title_match = re.search( + r"^title:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) if title_match: - metadata["title"] = title_match.group(1).strip().strip('"\'') + metadata["title"] = ( + title_match.group(1).strip().strip("\"'") + ) - author_match = re.search(r'^author:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE) + author_match = re.search( + r"^author:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) if author_match: - metadata["authors"] = [author_match.group(1).strip().strip('"\'')] + metadata["authors"] = [ + author_match.group(1).strip().strip("\"'") + ] - desc_match = re.search(r'^description:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE) + desc_match = re.search( + r"^description:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) if desc_match: - metadata["description"] = desc_match.group(1).strip().strip('"\'') + metadata["description"] = ( + desc_match.group(1).strip().strip("\"'") + ) - date_match = re.search(r'^date:\s*(.+)$', frontmatter, re.MULTILINE | re.IGNORECASE) + date_match = re.search( + r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE + ) if date_match: - date_str = date_match.group(1).strip().strip('"\'') - year_match = re.search(r'\b(19|20)\d{2}\b', date_str) + date_str = date_match.group(1).strip().strip("\"'") + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) if year_match: metadata["publication_year"] = year_match.group(0) except Exception as e: @@ -2068,9 +2138,11 @@ class HandlerDialog(QDialog): # Fallback: use first H1 header as title if no frontmatter title if not metadata["title"] and self.markdown_toc: # Find the first level 1 header - first_h1 = next((h for h in self.markdown_toc if h['level'] == 1), None) + first_h1 = next( + (h for h in self.markdown_toc if h["level"] == 1), None + ) if first_h1: - metadata["title"] = first_h1['name'] + metadata["title"] = first_h1["name"] else: pdf_info = self.pdf_doc.metadata if pdf_info: @@ -2117,7 +2189,10 @@ class HandlerDialog(QDialog): # preserve compatibility with callers that expect content to be ready # when they create a HandlerDialog and immediately request selected text. try: - if hasattr(self, '_loader_thread') and getattr(self, '_loader_thread') is not None: + if ( + hasattr(self, "_loader_thread") + and getattr(self, "_loader_thread") is not None + ): # Wait for thread to finish (blocks until done) if self._loader_thread.isRunning(): self._loader_thread.wait() @@ -2145,7 +2220,7 @@ class HandlerDialog(QDialog): authors_text = ", ".join(authors) album_artist = authors_text or "Unknown" year = ( - metadata.get("publication_year") or current_year + metadata.get("publication_year") or current_year ) # Use publication year if available # Count chapters/pages @@ -2248,7 +2323,7 @@ class HandlerDialog(QDialog): metadata_tags = self._format_metadata_tags() pdf_has_no_bookmarks = ( - hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks + hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks ) iterator = QTreeWidgetItemIterator(self.treeWidget) @@ -2288,9 +2363,9 @@ class HandlerDialog(QDialog): child = item.child(i) child_id = child.data(0, Qt.ItemDataRole.UserRole) if ( - child.checkState(0) == Qt.CheckState.Checked - and child_id - and child_id not in included_text_ids + child.checkState(0) == Qt.CheckState.Checked + and child_id + and child_id not in included_text_ids ): checked_children.append((child, child_id)) if parent_checked and parent_id and parent_id not in included_text_ids: @@ -2319,9 +2394,9 @@ class HandlerDialog(QDialog): elif item.flags() & Qt.ItemFlag.ItemIsUserCheckable: identifier = item.data(0, Qt.ItemDataRole.UserRole) if ( - identifier - and identifier not in included_text_ids - and item.checkState(0) == Qt.CheckState.Checked + identifier + and identifier not in included_text_ids + and item.checkState(0) == Qt.CheckState.Checked ): text = self.content_texts.get(identifier, "") if text: @@ -2374,7 +2449,9 @@ class HandlerDialog(QDialog): self.treeWidget.blockSignals(True) for item in self.treeWidget.selectedItems(): if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: - item.setCheckState(0, Qt.CheckState.Checked if state else Qt.CheckState.Unchecked) + item.setCheckState( + 0, Qt.CheckState.Checked if state else Qt.CheckState.Unchecked + ) self.treeWidget.blockSignals(False) self._update_checked_set_from_tree() @@ -2391,9 +2468,9 @@ class HandlerDialog(QDialog): return if ( - not item - or item.childCount() == 0 - or not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable) + not item + or item.childCount() == 0 + or not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable) ): return diff --git a/abogen/conversion.py b/abogen/conversion.py index 5be2c9f..f1dc1bd 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -6,7 +6,12 @@ from platformdirs import user_desktop_dir from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox import soundfile as sf -from abogen.utils import clean_text, create_process, get_user_cache_path, detect_encoding +from abogen.utils import ( + clean_text, + create_process, + get_user_cache_path, + detect_encoding, +) from abogen.constants import ( LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS, @@ -31,60 +36,62 @@ def get_sample_voice_text(lang_code): def sanitize_name_for_os(name, is_folder=True): """ Sanitize a filename or folder name based on the operating system. - + Args: name: The name to sanitize is_folder: Whether this is a folder name (default: True) - + Returns: Sanitized name safe for the current OS """ if not name: return "audiobook" - + system = platform.system() - + if system == "Windows": # Windows illegal characters: < > : " / \ | ? * # Also can't end with space or dot - sanitized = re.sub(r'[<>:"/\\|?*]', '_', name) + sanitized = re.sub(r'[<>:"/\\|?*]', "_", name) # Remove control characters (0-31) - sanitized = re.sub(r'[\x00-\x1f]', '_', sanitized) + sanitized = re.sub(r"[\x00-\x1f]", "_", sanitized) # Remove trailing spaces and dots - sanitized = sanitized.rstrip('. ') + sanitized = sanitized.rstrip(". ") # Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) - reserved = ['CON', 'PRN', 'AUX', 'NUL'] + \ - [f'COM{i}' for i in range(1, 10)] + \ - [f'LPT{i}' for i in range(1, 10)] - if sanitized.upper() in reserved or sanitized.upper().split('.')[0] in reserved: + reserved = ( + ["CON", "PRN", "AUX", "NUL"] + + [f"COM{i}" for i in range(1, 10)] + + [f"LPT{i}" for i in range(1, 10)] + ) + if sanitized.upper() in reserved or sanitized.upper().split(".")[0] in reserved: sanitized = f"_{sanitized}" elif system == "Darwin": # macOS # macOS illegal characters: : (colon is converted to / by the system) # Also can't start with dot (hidden file) for folders typically - sanitized = re.sub(r'[:]', '_', name) + sanitized = re.sub(r"[:]", "_", name) # Remove control characters - sanitized = re.sub(r'[\x00-\x1f]', '_', sanitized) + sanitized = re.sub(r"[\x00-\x1f]", "_", sanitized) # Avoid leading dot for folders (creates hidden folders) - if is_folder and sanitized.startswith('.'): - sanitized = '_' + sanitized[1:] + if is_folder and sanitized.startswith("."): + sanitized = "_" + sanitized[1:] else: # Linux and others # Linux illegal characters: / and null character # Though / is illegal, most other chars are technically allowed - sanitized = re.sub(r'[/\x00]', '_', name) + sanitized = re.sub(r"[/\x00]", "_", name) # Remove other control characters for safety - sanitized = re.sub(r'[\x01-\x1f]', '_', sanitized) + sanitized = re.sub(r"[\x01-\x1f]", "_", sanitized) # Avoid leading dot for folders (creates hidden folders) - if is_folder and sanitized.startswith('.'): - sanitized = '_' + sanitized[1:] - + if is_folder and sanitized.startswith("."): + sanitized = "_" + sanitized[1:] + # Ensure the name is not empty after sanitization - if not sanitized or sanitized.strip() == '': + if not sanitized or sanitized.strip() == "": sanitized = "audiobook" - + # Limit length to 255 characters (common limit across filesystems) if len(sanitized) > 255: - sanitized = sanitized[:255].rstrip('. ') - + sanitized = sanitized[:255].rstrip(". ") + return sanitized @@ -310,7 +317,11 @@ class ConversionThread(QThread): # Normalize paths for consistent display (fixes Windows path separator issues) input_file = os.path.normpath(input_file) if input_file else input_file - processing_file = os.path.normpath(processing_file) if processing_file else processing_file + processing_file = ( + os.path.normpath(processing_file) + if processing_file + else processing_file + ) self.log_updated.emit(f"- Input File: {input_file}") if input_file != processing_file: @@ -318,7 +329,9 @@ class ConversionThread(QThread): # Use file_name for logs if from_queue, otherwise use display_path if available if getattr(self, "from_queue", False): - base_path = self.save_base_path or self.file_name # Use save_base_path if available + base_path = ( + self.save_base_path or self.file_name + ) # Use save_base_path if available else: base_path = self.display_path if self.display_path else self.file_name @@ -476,14 +489,16 @@ class ConversionThread(QThread): # Use file_name for logs if from_queue, otherwise use display_path if available if getattr(self, "from_queue", False): - base_path = self.save_base_path or self.file_name # Use save_base_path if available + base_path = ( + self.save_base_path or self.file_name + ) # Use save_base_path if available else: base_path = self.display_path if self.display_path else self.file_name base_name = os.path.splitext(os.path.basename(base_path))[0] # Sanitize base_name for folder/file creation based on OS sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) - + if self.save_option == "Save to Desktop": parent_dir = user_desktop_dir() elif self.save_option == "Save next to input file": @@ -528,7 +543,9 @@ class ConversionThread(QThread): # Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True if merge_chapters_at_end: out_dir = parent_dir - base_filepath_no_ext = os.path.join(out_dir, f"{sanitized_base_name}{suffix}") + base_filepath_no_ext = os.path.join( + out_dir, f"{sanitized_base_name}{suffix}" + ) merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" subtitle_entries = [] current_time = 0.0 @@ -642,8 +659,12 @@ class ConversionThread(QThread): # Add style definitions for karaoke highlighting if self.subtitle_mode == "Sentence + Highlighting": merged_subtitle_file.write("[V4+ Styles]\n") - merged_subtitle_file.write("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n") - merged_subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n") + merged_subtitle_file.write( + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + merged_subtitle_file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) merged_subtitle_file.write("[Events]\n") merged_subtitle_file.write( "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" @@ -806,8 +827,12 @@ class ConversionThread(QThread): # Add style definitions for karaoke highlighting if self.subtitle_mode == "Sentence + Highlighting": chapter_subtitle_file.write("[V4+ Styles]\n") - chapter_subtitle_file.write("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n") - chapter_subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n") + chapter_subtitle_file.write( + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + chapter_subtitle_file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) chapter_subtitle_file.write("[Events]\n") chapter_subtitle_file.write( @@ -922,7 +947,12 @@ class ConversionThread(QThread): start_time = self._ass_time(start) end_time = self._ass_time(end) # Use karaoke effect for highlighting mode - effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else "" + effect = ( + "karaoke" + if self.subtitle_mode + == "Sentence + Highlighting" + else "" + ) merged_subtitle_file.write( f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n" ) @@ -951,7 +981,12 @@ class ConversionThread(QThread): start_time = self._ass_time(start) end_time = self._ass_time(end) # Use karaoke effect for highlighting mode - effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else "" + effect = ( + "karaoke" + if self.subtitle_mode + == "Sentence + Highlighting" + else "" + ) chapter_subtitle_file.write( f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n" ) @@ -997,15 +1032,17 @@ class ConversionThread(QThread): # Add silence between chapters for merged output (except after the last chapter) if merge_chapters_at_end and chapter_idx < total_chapters: - silence_samples = int(self.silence_duration * 24000) # Silence duration at 24,000 Hz + silence_samples = int( + self.silence_duration * 24000 + ) # Silence duration at 24,000 Hz silence_audio = self.np.zeros(silence_samples, dtype="float32") silence_bytes = silence_audio.tobytes() - + if merged_out_file: merged_out_file.write(silence_audio) elif ffmpeg_proc: ffmpeg_proc.stdin.write(silence_bytes) - + # Update timing for the silence current_time += self.silence_duration if chapter_out_file or chapter_ffmpeg_proc: @@ -1270,7 +1307,7 @@ class ConversionThread(QThread): """Helper function to process subtitle tokens according to the subtitle mode""" if not tokens_with_timestamps: return - + processed_tokens = tokens_with_timestamps # Use tokens directly # Use processed_tokens instead of tokens_with_timestamps for the rest of the method @@ -1297,7 +1334,11 @@ class ConversionThread(QThread): karaoke_text = "" for t in current_sentence: # Calculate duration in centiseconds - duration = t["end"] - t["start"] if t["end"] and t["start"] else 0.5 + duration = ( + t["end"] - t["start"] + if t["end"] and t["start"] + else 0.5 + ) duration_cs = int(duration * 100) # Add karaoke effect - relies on style's SecondaryColour for highlighting karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}" @@ -1394,22 +1435,35 @@ class ConversionThread(QThread): for token in processed_tokens: current_group.append(token) - + # Count spaces after tokens (in the whitespace field) if token.get("whitespace", "") == " ": space_count += 1 - + # Split after counting N spaces if space_count >= word_count: - text = "".join(t["text"] + (t.get("whitespace", "") or "") for t in current_group) - subtitle_entries.append((current_group[0]["start"], current_group[-1]["end"], text.strip())) + text = "".join( + t["text"] + (t.get("whitespace", "") or "") + for t in current_group + ) + subtitle_entries.append( + ( + current_group[0]["start"], + current_group[-1]["end"], + text.strip(), + ) + ) current_group = [] space_count = 0 # Add any remaining tokens if current_group: - text = "".join(t["text"] + (t.get("whitespace", "") or "") for t in current_group) - subtitle_entries.append((current_group[0]["start"], current_group[-1]["end"], text.strip())) + text = "".join( + t["text"] + (t.get("whitespace", "") or "") for t in current_group + ) + subtitle_entries.append( + (current_group[0]["start"], current_group[-1]["end"], text.strip()) + ) # Fallback for last entry if subtitle_entries and fallback_end_time is not None: @@ -1418,7 +1472,6 @@ class ConversionThread(QThread): if end is None or end <= start or end <= 0: subtitle_entries[-1] = (start, fallback_end_time, text) - def cancel(self): self.cancel_requested = True self.should_cancel = True diff --git a/abogen/gui.py b/abogen/gui.py index 229b2c6..03dc2dd 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -270,9 +270,9 @@ class InputBox(QLabel): if cache is not None: cached_char_count = cache.get(file_path) if ( - cached_char_count is None - and char_source_path - and char_source_path != file_path + cached_char_count is None + and char_source_path + and char_source_path != file_path ): cached_char_count = cache.get(char_source_path) @@ -280,7 +280,9 @@ class InputBox(QLabel): char_count = cached_char_count elif char_source_path: try: - with open(char_source_path, "r", encoding="utf-8", errors="ignore") as f: + with open( + char_source_path, "r", encoding="utf-8", errors="ignore" + ) as f: text = f.read() cleaned_text = clean_text(text) char_count = calculate_text_length(cleaned_text) @@ -328,8 +330,9 @@ class InputBox(QLabel): # For epub/pdf files, show edit if we have a selected_file (temp txt) if ( - window.selected_file_type in ["epub", "pdf", "md", "markdown", "md", "markdown"] - and window.selected_file + window.selected_file_type + in ["epub", "pdf", "md", "markdown", "md", "markdown"] + and window.selected_file ): should_show_edit = True @@ -401,10 +404,10 @@ class InputBox(QLabel): if urls: ext = urls[0].toLocalFile().lower() if ( - ext.endswith(".txt") - or ext.endswith(".epub") - or ext.endswith(".pdf") - or ext.endswith((".md", ".markdown")) + ext.endswith(".txt") + or ext.endswith(".epub") + or ext.endswith(".pdf") + or ext.endswith((".md", ".markdown")) ): event.acceptProposedAction() # Set hover style based on current state @@ -454,9 +457,11 @@ class InputBox(QLabel): ) self.set_file_info(file_path) event.acceptProposedAction() - elif (file_path.lower().endswith(".epub") - or file_path.lower().endswith(".pdf") - or file_path.lower().endswith((".md", ".markdown"))): + elif ( + file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown")) + ): # Determine file type if file_path.lower().endswith(".epub"): file_type = "epub" @@ -480,7 +485,10 @@ class InputBox(QLabel): def on_chapters_clicked(self): win = self.window() - if win.selected_file_type in ["epub", "pdf", "md", "markdown"] and win.selected_book_path: + if ( + win.selected_file_type in ["epub", "pdf", "md", "markdown"] + and win.selected_book_path + ): # Call open_book_file which shows the dialog and updates selected_chapters if win.open_book_file(win.selected_book_path): # Refresh the info label and button text after dialog closes @@ -492,7 +500,10 @@ class InputBox(QLabel): def on_edit_clicked(self): win = self.window() # For PDFs and EPUBs, use the temporary text file - if win.selected_file_type in ["epub", "pdf", "md", "markdown"] and win.selected_file: + if ( + win.selected_file_type in ["epub", "pdf", "md", "markdown"] + and win.selected_file + ): # Use the temporary .txt file that was generated win.open_textbox_dialog(win.selected_file) else: @@ -514,15 +525,22 @@ class InputBox(QLabel): is_cached_doc = False if ( - file_to_check - and os.path.exists(file_to_check) - and os.path.isfile(file_to_check) - and cache_dir + file_to_check + and os.path.exists(file_to_check) + and os.path.isfile(file_to_check) + and cache_dir ): # Consider it cached when the file is under the cache directory and is a .txt - if file_to_check.endswith('.txt') and os.path.commonpath([os.path.abspath(file_to_check), os.path.abspath(cache_dir)]) == os.path.abspath(cache_dir): + if file_to_check.endswith(".txt") and os.path.commonpath( + [os.path.abspath(file_to_check), os.path.abspath(cache_dir)] + ) == os.path.abspath(cache_dir): # Only treat as document-cache when original type was a document - if getattr(win, 'selected_file_type', None) in ['epub', 'pdf', 'md', 'markdown']: + if getattr(win, "selected_file_type", None) in [ + "epub", + "pdf", + "md", + "markdown", + ]: is_cached_doc = True if is_cached_doc: @@ -538,8 +556,11 @@ class InputBox(QLabel): act_input = QAction("Go to input file", self) # Prefer displayed_file_path (original input path) then selected_book_path - input_path = getattr(win, 'displayed_file_path', None) or getattr(win, 'selected_book_path', None) + input_path = getattr(win, "displayed_file_path", None) or getattr( + win, "selected_book_path", None + ) if input_path and os.path.exists(input_path): + def open_input(): folder_path = os.path.dirname(input_path) QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) @@ -550,12 +571,16 @@ class InputBox(QLabel): menu.addAction(act_input) # Show the menu anchored to the button - menu.exec(self.go_to_folder_btn.mapToGlobal(QPoint(0, self.go_to_folder_btn.height()))) + menu.exec( + self.go_to_folder_btn.mapToGlobal( + QPoint(0, self.go_to_folder_btn.height()) + ) + ) else: if ( - file_to_check - and os.path.exists(file_to_check) - and os.path.isfile(file_to_check) + file_to_check + and os.path.exists(file_to_check) + and os.path.isfile(file_to_check) ): folder_path = os.path.dirname(file_to_check) QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) @@ -568,7 +593,9 @@ class TextboxDialog(QDialog): super().__init__(parent) self.setWindowTitle("Enter Text") self.setWindowFlags( - Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowMaximizeButtonHint + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint ) self.resize(700, 500) @@ -648,7 +675,9 @@ class TextboxDialog(QDialog): f"You are about to overwrite the original file:\n{self.non_cache_file_path}" ) msg_box.setInformativeText("Do you want to continue?") - msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) msg_box.setDefaultButton(QMessageBox.StandardButton.No) if msg_box.exec() != QMessageBox.StandardButton.Yes: @@ -674,8 +703,8 @@ class TextboxDialog(QDialog): # This gives a better filename instead of the cache file path main_window = self.parent() if ( - hasattr(main_window, "displayed_file_path") - and main_window.displayed_file_path + hasattr(main_window, "displayed_file_path") + and main_window.displayed_file_path ): if main_window.selected_file_type in ["epub", "pdf", "md", "markdown"]: # Use the base name of the displayed file but change extension to .txt @@ -918,7 +947,9 @@ class abogen(QWidget): "The first character represents the language:\n" '"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female' ) - self.voice_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.voice_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) voice_layout.addWidget(self.voice_combo) # Voice formula button self.btn_voice_formula_mixer = QPushButton(self) @@ -984,14 +1015,20 @@ class abogen(QWidget): for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION ) ) - subtitle_options = ["Disabled", "Line", "Sentence", "Sentence + Comma", "Sentence + Highlighting"] + [ - f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11) - ] + subtitle_options = [ + "Disabled", + "Line", + "Sentence", + "Sentence + Comma", + "Sentence + Highlighting", + ] + [f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11)] self.subtitle_combo.addItems(subtitle_options) self.subtitle_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) - self.subtitle_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.subtitle_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) self.subtitle_combo.setCurrentText(self.subtitle_mode) self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) # Enable/disable subtitle options based on selected language (profile or voice) @@ -1009,7 +1046,9 @@ class abogen(QWidget): self.format_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) - self.format_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.format_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) # Add items with display labels and underlying keys for key, label in [ ("wav", "wav"), @@ -1055,7 +1094,10 @@ class abogen(QWidget): # If subtitle mode requires highlighting, SRT is not supported. Disable SRT item # and auto-switch to a compatible ASS format if SRT is currently selected. try: - if hasattr(self, "subtitle_mode") and self.subtitle_mode == "Sentence + Highlighting": + if ( + hasattr(self, "subtitle_mode") + and self.subtitle_mode == "Sentence + Highlighting" + ): idx_srt = self.subtitle_format_combo.findData("srt") if idx_srt >= 0: item = self.subtitle_format_combo.model().item(idx_srt) @@ -1067,7 +1109,9 @@ class abogen(QWidget): if new_idx >= 0: self.subtitle_format_combo.setCurrentIndex(new_idx) # Persist the change - self.set_subtitle_format(self.subtitle_format_combo.itemData(new_idx)) + self.set_subtitle_format( + self.subtitle_format_combo.itemData(new_idx) + ) except Exception: # Fail-safe: don't crash UI if model manipulation isn't supported on some platforms pass @@ -1114,7 +1158,9 @@ class abogen(QWidget): self.save_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) - self.save_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.save_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) self.save_combo.setCurrentText(self.save_option) self.save_combo.currentTextChanged.connect(self.on_save_option_changed) save_layout.addWidget(self.save_combo) @@ -1129,7 +1175,9 @@ class abogen(QWidget): save_path_row.addWidget(selected_folder_label) self.save_path_label = QLabel("", self.save_path_row_widget) self.save_path_label.setStyleSheet(f"QLabel {{ color: {COLORS['GREEN']}; }}") - self.save_path_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.save_path_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) save_path_row.addWidget(self.save_path_label) self.save_path_row_widget.hide() # Hide the whole row by default controls_layout.addWidget(self.save_path_row_widget) @@ -1175,7 +1223,9 @@ class abogen(QWidget): # Add controls to a container widget self.controls_widget = QWidget() self.controls_widget.setLayout(controls_layout) - self.controls_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.controls_widget.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) container_layout.addWidget(self.controls_widget) # Progress bar self.progress_bar = QProgressBar(self) @@ -1241,9 +1291,11 @@ class abogen(QWidget): ) if not file_path: return - if (file_path.lower().endswith(".epub") - or file_path.lower().endswith(".pdf") - or file_path.lower().endswith((".md", ".markdown"))): + if ( + file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown")) + ): # Determine file type if file_path.lower().endswith(".epub"): self.selected_file_type = "epub" @@ -1270,8 +1322,8 @@ class abogen(QWidget): def open_book_file(self, book_path): # Clear selected chapters if this is a different book than the last one if ( - not hasattr(self, "last_opened_book_path") - or self.last_opened_book_path != book_path + not hasattr(self, "last_opened_book_path") + or self.last_opened_book_path != book_path ): self.selected_chapters = set() self.last_opened_book_path = book_path @@ -1279,9 +1331,9 @@ class abogen(QWidget): # HandlerDialog uses internal caching to avoid reprocessing the same book dialog = HandlerDialog( book_path, - file_type=getattr(self, 'selected_file_type', None), + file_type=getattr(self, "selected_file_type", None), checked_chapters=self.selected_chapters, - parent=self + parent=self, ) dialog.setWindowModality(Qt.WindowModality.NonModal) dialog.setModal(False) @@ -1429,9 +1481,9 @@ class abogen(QWidget): is_cache_file = get_user_cache_path() in file_path # Otherwise use selected_file if it's a txt file elif ( - self.selected_file_type == "txt" - and self.selected_file - and os.path.exists(self.selected_file) + self.selected_file_type == "txt" + and self.selected_file + and os.path.exists(self.selected_file) ): editing = True edit_file = self.selected_file @@ -1642,9 +1694,9 @@ class abogen(QWidget): def _get_queue_progress_format(self, value=None): """Return the progress bar format string for queue mode.""" if ( - hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") ): N = self.current_queue_index + 1 M = len(self.queued_items) @@ -1661,9 +1713,9 @@ class abogen(QWidget): self.progress_bar.setValue(value) # Show queue progress if in queue mode if ( - hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") ): N = self.current_queue_index + 1 M = len(self.queued_items) @@ -1724,7 +1776,9 @@ class abogen(QWidget): if self.selected_file_type in ["epub", "pdf", "md", "markdown"]: file_to_queue = self.selected_file # Use the original file path for save location - save_base_path = self.displayed_file_path if self.displayed_file_path else file_to_queue + save_base_path = ( + self.displayed_file_path if self.displayed_file_path else file_to_queue + ) else: file_to_queue = ( self.displayed_file_path @@ -1732,7 +1786,7 @@ class abogen(QWidget): else self.selected_file ) save_base_path = file_to_queue # For non-EPUB, it's the same - + if not file_to_queue: self.input_box.set_error("Please add a file.") return @@ -1759,22 +1813,22 @@ class abogen(QWidget): # Prevent adding duplicate items to the queue for queued_item in self.queued_items: if ( - queued_item.file_name == item_queue.file_name - and queued_item.lang_code == item_queue.lang_code - and queued_item.speed == item_queue.speed - and queued_item.voice == item_queue.voice - and queued_item.save_option == item_queue.save_option - and queued_item.output_folder == item_queue.output_folder - and queued_item.subtitle_mode == item_queue.subtitle_mode - and queued_item.output_format == item_queue.output_format - and getattr(queued_item, "replace_single_newlines", False) - == item_queue.replace_single_newlines - and getattr(queued_item, "save_base_path", None) - == item_queue.save_base_path - and getattr(queued_item, "save_chapters_separately", None) - == item_queue.save_chapters_separately - and getattr(queued_item, "merge_chapters_at_end", None) - == item_queue.merge_chapters_at_end + queued_item.file_name == item_queue.file_name + and queued_item.lang_code == item_queue.lang_code + and queued_item.speed == item_queue.speed + and queued_item.voice == item_queue.voice + and queued_item.save_option == item_queue.save_option + and queued_item.output_folder == item_queue.output_folder + and queued_item.subtitle_mode == item_queue.subtitle_mode + and queued_item.output_format == item_queue.output_format + and getattr(queued_item, "replace_single_newlines", False) + == item_queue.replace_single_newlines + and getattr(queued_item, "save_base_path", None) + == item_queue.save_base_path + and getattr(queued_item, "save_chapters_separately", None) + == item_queue.save_chapters_separately + and getattr(queued_item, "merge_chapters_at_end", None) + == item_queue.merge_chapters_at_end ): QMessageBox.warning( self, "Duplicate Item", "This item is already in the queue." @@ -1835,7 +1889,9 @@ class abogen(QWidget): queued_item, "replace_single_newlines", False ) # Restore the original file path for save location - self.displayed_file_path = queued_item.save_base_path or queued_item.file_name + self.displayed_file_path = ( + queued_item.save_base_path or queued_item.file_name + ) self.start_conversion(from_queue=True) else: # Queue finished, reset index @@ -1897,10 +1953,10 @@ class abogen(QWidget): self.progress_bar.setValue(0) # Show queue progress if in queue mode if ( - from_queue - and hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") + from_queue + and hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") ): N = self.current_queue_index + 1 M = len(self.queued_items) @@ -1987,7 +2043,7 @@ class abogen(QWidget): ) # Pass chapter count for EPUB or PDF files if self.selected_file_type in ["epub", "pdf", "md", "markdown"] and hasattr( - self, "selected_chapters" + self, "selected_chapters" ): self.conversion_thread.chapter_count = len(self.selected_chapters) # Pass save_chapters_separately flag if available @@ -2130,8 +2186,8 @@ class abogen(QWidget): # Only show finish_widget if queue is done if ( - self.current_queue_index + 1 >= len(self.queued_items) - or not self.queued_items + self.current_queue_index + 1 >= len(self.queued_items) + or not self.queued_items ): # Queue finished, show finish screen self.controls_widget.hide() @@ -2496,14 +2552,14 @@ class abogen(QWidget): def cleanup(): # Only remove if not from cache AND it's a temp file from VoicePreviewThread if ( - not from_cache - and hasattr(self, "preview_thread") - and hasattr(self.preview_thread, "temp_wav") - and self.preview_thread.temp_wav == temp_wav + not from_cache + and hasattr(self, "preview_thread") + and hasattr(self.preview_thread, "temp_wav") + and self.preview_thread.temp_wav == temp_wav ): try: if os.path.exists( - temp_wav + temp_wav ): # Ensure it exists before trying to remove os.remove(temp_wav) except Exception: @@ -2571,14 +2627,16 @@ class abogen(QWidget): box.setText( "A conversion is currently running. Are you sure you want to cancel?" ) - box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) box.setDefaultButton(QMessageBox.StandardButton.No) if box.exec() != QMessageBox.StandardButton.Yes: return try: if ( - hasattr(self, "conversion_thread") - and self.conversion_thread.isRunning() + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() ): if not hasattr(self, "_conversion_lock"): self._conversion_lock = threading.Lock() @@ -2641,7 +2699,9 @@ class abogen(QWidget): new_idx = self.subtitle_format_combo.findData("ass_centered_narrow") if new_idx >= 0: self.subtitle_format_combo.setCurrentIndex(new_idx) - self.set_subtitle_format(self.subtitle_format_combo.itemData(new_idx)) + self.set_subtitle_format( + self.subtitle_format_combo.itemData(new_idx) + ) else: # Re-enable SRT option when not in highlighting mode if idx_srt >= 0: @@ -2665,9 +2725,9 @@ class abogen(QWidget): def cleanup_conversion_thread(self): # Stop conversion thread if ( - hasattr(self, "conversion_thread") - and self.conversion_thread is not None - and self.conversion_thread.isRunning() + hasattr(self, "conversion_thread") + and self.conversion_thread is not None + and self.conversion_thread.isRunning() ): self.conversion_thread.cancel() self.conversion_thread.wait() @@ -2680,7 +2740,9 @@ class abogen(QWidget): box.setText( "A conversion is currently running. Are you sure you want to exit?" ) - box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) box.setDefaultButton(QMessageBox.StandardButton.No) if box.exec() == QMessageBox.StandardButton.Yes: self.cleanup_conversion_thread() @@ -2702,8 +2764,8 @@ class abogen(QWidget): if dialog.exec() == QDialog.DialogCode.Accepted: options = dialog.get_options() if ( - hasattr(self, "conversion_thread") - and self.conversion_thread.isRunning() + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() ): self.conversion_thread.set_chapter_options(options) else: @@ -2712,7 +2774,6 @@ class abogen(QWidget): def apply_theme(self, theme): - app = QApplication.instance() is_windows = platform.system() == "Windows" available_styles = [s.lower() for s in QStyleFactory.keys()] @@ -2722,8 +2783,8 @@ class abogen(QWidget): import winreg with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", ) as key: value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") return value == 0 @@ -2819,7 +2880,7 @@ class abogen(QWidget): # Main logic dark_mode = theme == "dark" or ( - theme == "system" and is_windows and is_windows_dark_mode() + theme == "system" and is_windows and is_windows_dark_mode() ) if dark_mode: app.setStyle("Fusion") @@ -2855,7 +2916,7 @@ class abogen(QWidget): def get_dark_mode(): return theme == "dark" or ( - theme == "system" and is_windows and is_windows_dark_mode() + theme == "system" and is_windows and is_windows_dark_mode() ) app._dark_titlebar_event_filter = DarkTitleBarEventFilter( @@ -3305,7 +3366,9 @@ Categories=AudioVideo;Audio;Utility; # Create custom dialog dialog = QDialog(self) dialog.setWindowTitle(f"About {PROGRAM_NAME}") - dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) + dialog.setWindowFlags( + dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint + ) dialog.setFixedSize(400, 320) # Increased height for new button layout = QVBoxLayout(dialog) @@ -3384,7 +3447,9 @@ Categories=AudioVideo;Audio;Utility; "Alternatively, visit the GitHub repository for more information. " "Would you like to view the changelog?" ) - msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) if msg_box.exec() == QMessageBox.StandardButton.Yes: try: @@ -3394,8 +3459,8 @@ Categories=AudioVideo;Audio;Utility; # Reset flag to track if we should show "no updates" message show_result = ( - hasattr(self, "_show_update_check_result") - and self._show_update_check_result + hasattr(self, "_show_update_check_result") + and self._show_update_check_result ) self._show_update_check_result = False @@ -3487,7 +3552,9 @@ Categories=AudioVideo;Audio;Utility; msg_box.setCheckBox(preview_cache_checkbox) # Add buttons - msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) if msg_box.exec() != QMessageBox.StandardButton.Yes: @@ -3522,9 +3589,9 @@ Categories=AudioVideo;Audio;Utility; # If currently selected file is in the cache directory, clear the UI if ( - self.selected_file - and os.path.dirname(self.selected_file) == cache_dir - and self.selected_file.endswith(".txt") + self.selected_file + and os.path.dirname(self.selected_file) == cache_dir + and self.selected_file.endswith(".txt") ): self.input_box.clear_input() @@ -3588,7 +3655,6 @@ Categories=AudioVideo;Audio;Utility; def set_silence_between_chapters(self): """Open a dialog to set the silence duration between chapters""" - current_value = self.config.get("silence_duration", 2.0) dlg = QInputDialog(self) @@ -3619,6 +3685,7 @@ Categories=AudioVideo;Audio;Utility; "Setting Saved", f"Silence duration between chapters set to {value:.1f} seconds.", ) + def set_separate_chapters_format(self, fmt): """Set the format for separate chapters audio files.""" self.separate_chapters_format = fmt diff --git a/abogen/main.py b/abogen/main.py index ac4a2c6..1b3c1ff 100644 --- a/abogen/main.py +++ b/abogen/main.py @@ -8,16 +8,16 @@ import signal # Qt platform plugin detection (fixes #59) try: from PyQt6.QtCore import QLibraryInfo - + # Get the path to the plugins directory plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath) - + # Normalize path to use the OS-native separators and absolute path platform_dir = os.path.normpath(os.path.join(plugins, "platforms")) - + # Ensure we work with an absolute path for clarity platform_dir = os.path.abspath(platform_dir) - + if os.path.isdir(platform_dir): os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir) @@ -31,6 +31,7 @@ if platform.system() == "Windows": try: from abogen.constants import PROGRAM_NAME, VERSION import ctypes + app_id = f"{PROGRAM_NAME}.{VERSION}" ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) except Exception as e: diff --git a/abogen/queue_manager_gui.py b/abogen/queue_manager_gui.py index 3eae79a..5fe372f 100644 --- a/abogen/queue_manager_gui.py +++ b/abogen/queue_manager_gui.py @@ -36,7 +36,9 @@ class ElidedLabel(QLabel): def resizeEvent(self, event): metrics = QFontMetrics(self.font()) - elided = metrics.elidedText(self._full_text, Qt.TextElideMode.ElideRight, self.width()) + elided = metrics.elidedText( + self._full_text, Qt.TextElideMode.ElideRight, self.width() + ) super().setText(elided) super().resizeEvent(event) @@ -55,8 +57,12 @@ class QueueListItemWidget(QWidget): name_label = ElidedLabel(os.path.basename(file_name)) char_label = QLabel(f"Chars: {char_count}") char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};") - char_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - char_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) + char_label.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + char_label.setSizePolicy( + QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred + ) layout.addWidget(name_label, 1) layout.addWidget(char_label, 0) self.setLayout(layout) @@ -74,7 +80,9 @@ class DroppableQueueListWidget(QListWidget): f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};" ) self.drag_overlay.setVisible(False) - self.drag_overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self.drag_overlay.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, True + ) def dragEnterEvent(self, event): if event.mimeData().hasUrls(): @@ -134,7 +142,9 @@ class QueueManager(QDialog): layout.setSpacing(12) # set spacing between widgets in main layout # list of queued items self.listwidget = DroppableQueueListWidget(self) - self.listwidget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.listwidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) self.listwidget.setAlternatingRowColors(True) self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.listwidget.customContextMenuRequested.connect(self.show_context_menu) @@ -161,7 +171,9 @@ class QueueManager(QDialog): f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;" ) self.empty_overlay.setWordWrap(True) - self.empty_overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self.empty_overlay.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, True + ) self.empty_overlay.hide() # add queue items to the list self.process_queue() @@ -194,7 +206,10 @@ class QueueManager(QDialog): self.listwidget.currentItemChanged.connect(self.update_button_states) self.listwidget.itemSelectionChanged.connect(self.update_button_states) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) @@ -210,7 +225,7 @@ class QueueManager(QDialog): def process_queue(self): """Process the queue items.""" import os - + self.listwidget.clear() if not self.queue: self.empty_overlay.show() @@ -223,11 +238,19 @@ class QueueManager(QDialog): # Determine display file path (prefer save_base_path for original file) display_file_path = getattr(item, "save_base_path", None) or item.file_name processing_file_path = item.file_name - + # Normalize paths for consistent display (fixes Windows path separator issues) - display_file_path = os.path.normpath(display_file_path) if display_file_path else display_file_path - processing_file_path = os.path.normpath(processing_file_path) if processing_file_path else processing_file_path - + display_file_path = ( + os.path.normpath(display_file_path) + if display_file_path + else display_file_path + ) + processing_file_path = ( + os.path.normpath(processing_file_path) + if processing_file_path + else processing_file_path + ) + # Only show the file name, not the full path display_name = display_file_path @@ -241,13 +264,19 @@ class QueueManager(QDialog): # For plain .txt inputs we don't need to show a separate processing file show_processing = True try: - if isinstance(display_file_path, str) and display_file_path.lower().endswith('.txt'): + if isinstance( + display_file_path, str + ) and display_file_path.lower().endswith(".txt"): show_processing = False except Exception: show_processing = True tooltip = f"Input File: {display_file_path}
" - if show_processing and processing_file_path and processing_file_path != display_file_path: + if ( + show_processing + and processing_file_path + and processing_file_path != display_file_path + ): tooltip += f"Processing File: {processing_file_path}
" tooltip += ( f"Language: {getattr(item, 'lang_code', '')}
" @@ -264,8 +293,8 @@ class QueueManager(QDialog): f"Replace Single Newlines: {getattr(item, 'replace_single_newlines', False)}" ) # Add book handler options if present - save_chapters_separately = getattr(item, 'save_chapters_separately', None) - merge_chapters_at_end = getattr(item, 'merge_chapters_at_end', None) + save_chapters_separately = getattr(item, "save_chapters_separately", None) + merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None) if save_chapters_separately is not None: tooltip += f"
Save chapters separately: {'Yes' if save_chapters_separately else 'No'}" # Only show merge option if saving chapters separately @@ -274,10 +303,13 @@ class QueueManager(QDialog): list_item.setToolTip(tooltip) list_item.setIcon(icon) # Store both paths for context menu - list_item.setData(Qt.ItemDataRole.UserRole, { - 'display_path': display_file_path, - 'processing_path': processing_file_path - }) + list_item.setData( + Qt.ItemDataRole.UserRole, + { + "display_path": display_file_path, + "processing_path": processing_file_path, + }, + ) # Use custom widget for display char_count = getattr(item, "total_char_count", 0) widget = QueueListItemWidget(display_file_path, char_count) @@ -412,7 +444,9 @@ class QueueManager(QDialog): item = QueueItem() item.file_name = file_path - item.save_base_path = file_path # For .txt files, processing and save paths are the same + item.save_base_path = ( + file_path # For .txt files, processing and save paths are the same + ) for attr, value in current_attrs.items(): setattr(item, attr, value) # Read file content and calculate total_char_count using calculate_text_length @@ -517,66 +551,86 @@ class QueueManager(QDialog): item = selected_items[0] paths = item.data(Qt.ItemDataRole.UserRole) if isinstance(paths, dict): - display_path = paths.get('display_path', '') - processing_path = paths.get('processing_path', '') + display_path = paths.get("display_path", "") + processing_path = paths.get("processing_path", "") else: display_path = paths processing_path = paths - doc_exts = ('.md', '.markdown', '.pdf', '.epub') + doc_exts = (".md", ".markdown", ".pdf", ".epub") is_document_input = ( - isinstance(display_path, str) and display_path.lower().endswith(doc_exts) + isinstance(display_path, str) + and display_path.lower().endswith(doc_exts) ) or ( - isinstance(processing_path, str) and processing_path.lower().endswith(doc_exts) + isinstance(processing_path, str) + and processing_path.lower().endswith(doc_exts) ) # Add Open file action(s) def open_file_by_path(path_label: str): from PyQt6.QtWidgets import QMessageBox - p = display_path if path_label == 'display' else processing_path + p = display_path if path_label == "display" else processing_path if not p: - QMessageBox.warning(self, "File Not Found", "Path is not available.") + QMessageBox.warning( + self, "File Not Found", "Path is not available." + ) return - + # Find the queue item and resolve the target path target_path = None for q in self.queue: - if getattr(q, 'save_base_path', None) == display_path or q.file_name == display_path: - if path_label == 'display': - target_path = getattr(q, 'save_base_path', None) or q.file_name + if ( + getattr(q, "save_base_path", None) == display_path + or q.file_name == display_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) else: target_path = q.file_name break - if getattr(q, 'save_base_path', None) == processing_path or q.file_name == processing_path: - if path_label == 'display': - target_path = getattr(q, 'save_base_path', None) or q.file_name + if ( + getattr(q, "save_base_path", None) == processing_path + or q.file_name == processing_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) else: target_path = q.file_name break - + # Fallback to the raw path if resolution failed if not target_path: target_path = p - + if not os.path.exists(target_path): - QMessageBox.warning(self, "File Not Found", f"The file does not exist.") + QMessageBox.warning( + self, "File Not Found", f"The file does not exist." + ) return QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) if is_document_input: # For documents, show two open options open_processed_action = QAction("Open processed file", self) - open_processed_action.triggered.connect(lambda: open_file_by_path('processing')) + open_processed_action.triggered.connect( + lambda: open_file_by_path("processing") + ) menu.addAction(open_processed_action) open_input_action = QAction("Open input file", self) - open_input_action.triggered.connect(lambda: open_file_by_path('display')) + open_input_action.triggered.connect( + lambda: open_file_by_path("display") + ) menu.addAction(open_input_action) else: # For plain text files, show single open option open_file_action = QAction("Open file", self) - open_file_action.triggered.connect(lambda: open_file_by_path('display')) + open_file_action.triggered.connect(lambda: open_file_by_path("display")) menu.addAction(open_file_action) # Add Go to folder action @@ -587,23 +641,35 @@ class QueueManager(QDialog): def open_folder_for(path_label: str): # path_label should be either 'display' or 'processing' - p = display_path if path_label == 'display' else processing_path + p = display_path if path_label == "display" else processing_path if not p: - QMessageBox.warning(self, "File Not Found", "Path is not available.") + QMessageBox.warning( + self, "File Not Found", "Path is not available." + ) return # If the stored path is the display path (original) but the actual file may be # stored on the queue object differently, try to resolve via the queue entry. target_path = None for q in self.queue: - if getattr(q, 'save_base_path', None) == display_path or q.file_name == display_path: - if path_label == 'display': - target_path = getattr(q, 'save_base_path', None) or q.file_name + if ( + getattr(q, "save_base_path", None) == display_path + or q.file_name == display_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) else: target_path = q.file_name break - if getattr(q, 'save_base_path', None) == processing_path or q.file_name == processing_path: - if path_label == 'display': - target_path = getattr(q, 'save_base_path', None) or q.file_name + if ( + getattr(q, "save_base_path", None) == processing_path + or q.file_name == processing_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) else: target_path = q.file_name break @@ -612,7 +678,11 @@ class QueueManager(QDialog): target_path = p if not os.path.exists(target_path): - QMessageBox.warning(self, "File Not Found", f"The file does not exist: {target_path}") + QMessageBox.warning( + self, + "File Not Found", + f"The file does not exist: {target_path}", + ) return folder = os.path.dirname(target_path) if os.path.exists(folder): @@ -620,11 +690,13 @@ class QueueManager(QDialog): if is_document_input: processed_action = QAction("Go to processed file", self) - processed_action.triggered.connect(lambda: open_folder_for('processing')) + processed_action.triggered.connect( + lambda: open_folder_for("processing") + ) menu.addAction(processed_action) input_action = QAction("Go to input file", self) - input_action.triggered.connect(lambda: open_folder_for('display')) + input_action.triggered.connect(lambda: open_folder_for("display")) menu.addAction(input_action) else: # Default behavior for non-document inputs: single "Go to folder" action @@ -634,13 +706,20 @@ class QueueManager(QDialog): item = selected_items[0] paths = item.data(Qt.ItemDataRole.UserRole) if isinstance(paths, dict): - file_path = paths.get('display_path', paths.get('processing_path', '')) + file_path = paths.get( + "display_path", paths.get("processing_path", "") + ) else: file_path = paths # Fallback for old format # Find the queue item for q in self.queue: - if (getattr(q, "save_base_path", None) == file_path or q.file_name == file_path): - target_path = getattr(q, "save_base_path", None) or q.file_name + if ( + getattr(q, "save_base_path", None) == file_path + or q.file_name == file_path + ): + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) if not os.path.exists(target_path): QMessageBox.warning( self, "File Not Found", f"The file does not exist." diff --git a/abogen/utils.py b/abogen/utils.py index 0435d77..f93208e 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -14,6 +14,7 @@ warnings.filterwarnings("ignore") def detect_encoding(file_path): import chardet import charset_normalizer + with open(file_path, "rb") as f: raw_data = f.read() detected_encoding = None @@ -28,6 +29,7 @@ def detect_encoding(file_path): encoding = detected_encoding if detected_encoding else "utf-8" return encoding.lower() + def get_resource_path(package, resource): """ Get the path to a resource file, with fallback to local file system. @@ -261,18 +263,18 @@ def get_gpu_acceleration(enabled): if not enabled: return "GPU available but using CPU.", False - + # Check for Apple Silicon MPS if platform.system() == "Darwin" and platform.processor() == "arm": if torch.backends.mps.is_available(): return "MPS GPU available and enabled.", True else: return "MPS GPU not available on Apple Silicon. Using CPU.", False - + # Check for CUDA if cuda_available(): return "CUDA GPU available and enabled.", True - + # Gather CUDA diagnostic info if not available try: cuda_devices = torch.cuda.device_count() diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 0bd0bfc..33cc2b0 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -131,10 +131,14 @@ class FlowLayout(QLayout): for item in self._item_list: style = self.parentWidget().style() if self.parentWidget() else QStyle() layout_spacing_x = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, QSizePolicy.ControlType.PushButton, Qt.Orientation.Horizontal + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Horizontal, ) layout_spacing_y = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, QSizePolicy.ControlType.PushButton, Qt.Orientation.Vertical + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Vertical, ) space_x = spacing if spacing >= 0 else layout_spacing_x space_y = spacing if spacing >= 0 else layout_spacing_y @@ -180,7 +184,9 @@ class VoiceMixer(QWidget): # Icons layout (flag and gender) icons_layout = QHBoxLayout() icons_layout.setSpacing(3) - icons_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) # Center the icons horizontally + icons_layout.setAlignment( + Qt.AlignmentFlag.AlignCenter + ) # Center the icons horizontally # Flag icon flag_icon_path = get_resource_path( @@ -193,11 +199,21 @@ class VoiceMixer(QWidget): gender_label = QLabel() flag_pixmap = QPixmap(flag_icon_path) flag_label.setPixmap( - flag_pixmap.scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + flag_pixmap.scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) ) gender_pixmap = QPixmap(gender_icon_path) gender_label.setPixmap( - gender_pixmap.scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + gender_pixmap.scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) ) icons_layout.addWidget(flag_label) icons_layout.addWidget(gender_label) @@ -221,7 +237,9 @@ class VoiceMixer(QWidget): self.slider = QSlider(Qt.Orientation.Vertical) self.slider.setRange(0, 100) self.slider.setValue(int(initial_weight * 100)) - self.slider.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + self.slider.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding + ) self.slider.setFixedWidth(SLIDER_WIDTH) # Fix slider in Windows @@ -251,7 +269,9 @@ class VoiceMixer(QWidget): slider_layout.addWidget(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter)) slider_center_layout = QHBoxLayout() - slider_center_layout.addWidget(self.slider, alignment=Qt.AlignmentFlag.AlignHCenter) + slider_center_layout.addWidget( + self.slider, alignment=Qt.AlignmentFlag.AlignHCenter + ) slider_center_layout.setContentsMargins(0, 0, 0, 0) slider_center_widget = QWidget() @@ -307,7 +327,9 @@ class HoverLabel(QLabel): ) # Make sure the entire button is clickable, not just the text self.delete_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.delete_button.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) + self.delete_button.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, False + ) self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) self.delete_button.hide() @@ -408,7 +430,9 @@ class VoiceFormulaDialog(QDialog): self.setWindowTitle("Voice Mixer") self.setWindowFlags( - Qt.WindowType.Window | Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowMaximizeButtonHint + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint ) self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) @@ -473,8 +497,12 @@ class VoiceFormulaDialog(QDialog): # Voice list scroll area self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) - self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) self.scroll_area.viewport().installEventFilter(self) self.voice_list_widget = QWidget() @@ -564,7 +592,9 @@ class VoiceFormulaDialog(QDialog): self, "Unsaved Changes", msg, - QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Save, ) if ret == QMessageBox.StandardButton.Save: @@ -620,7 +650,9 @@ class VoiceFormulaDialog(QDialog): "You have unsaved changes in your profile. Do you want to save the changes?" ) box.setStandardButtons( - QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel ) box.setDefaultButton(QMessageBox.StandardButton.Save) ret = box.exec() @@ -769,7 +801,9 @@ class VoiceFormulaDialog(QDialog): f'{name}: {percentage:.1f}%', name, ) - voice_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + voice_label.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred + ) voice_label.delete_button.clicked.connect( lambda _, vn=name: self.disable_voice_by_name(vn) ) @@ -1138,7 +1172,10 @@ class VoiceFormulaDialog(QDialog): msg += f"\nThis will overwrite an existing profile." msg += "\nContinue?" reply = QMessageBox.question( - self, "Import Profile", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + self, + "Import Profile", + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return @@ -1155,7 +1192,10 @@ class VoiceFormulaDialog(QDialog): msg += f"\n{len(collisions)} profile(s) will be overwritten." msg += "\nContinue?" reply = QMessageBox.question( - self, "Import Profiles", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + self, + "Import Profiles", + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return @@ -1394,7 +1434,8 @@ class VoiceFormulaDialog(QDialog): item.setData(Qt.ItemDataRole.BackgroundRole, color) else: item.setData( - Qt.ItemDataRole.BackgroundRole, self.profile_list.palette().base().color() + Qt.ItemDataRole.BackgroundRole, + self.profile_list.palette().base().color(), ) weights = profiles.get(name, {}).get("voices", []) total = 0