Reformat using black

This commit is contained in:
Deniz Şafak
2025-10-27 17:26:54 +03:00
parent 9d2e8bed00
commit 47fe6341b4
7 changed files with 701 additions and 381 deletions
+219 -142
View File
@@ -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"<p style='text-align: center; font-style: italic;'>By {authors_text}</p>"
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
+101 -48
View File
@@ -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
+179 -112
View File
@@ -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
+5 -4
View File
@@ -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:
+134 -55
View File
@@ -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"<b>Input File:</b> {display_file_path}<br>"
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"<b>Processing File:</b> {processing_file_path}<br>"
tooltip += (
f"<b>Language:</b> {getattr(item, 'lang_code', '')}<br>"
@@ -264,8 +293,8 @@ class QueueManager(QDialog):
f"<b>Replace Single Newlines:</b> {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"<br><b>Save chapters separately:</b> {'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."
+5 -3
View File
@@ -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()
+58 -17
View File
@@ -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'<b><span style="color:{COLORS.get("BLUE")}">{name}: {percentage:.1f}%</span></b>',
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