18 Commits
Author SHA1 Message Date
Deniz Şafak f5d1def330 v1.2.0 2025-10-20 03:57:16 +03:00
Deniz Şafak 4a28bfb859 Fixed “Go to folder” button not opening the chapter output directory when only separate chapters were generated 2025-10-20 03:51:27 +03:00
Deniz Şafak 4adda262c0 Fix total character count 2025-10-20 03:35:20 +03:00
Deniz Şafak 1d02802172 Add "How to fix "[WinError 1114]" error to readme 2025-10-20 03:21:27 +03:00
Deniz Şafak 28c0b2c401 Use a newer Python in Windows installer script 2025-10-20 02:29:16 +03:00
Deniz Şafak 1e89b566d1 Added a loading indicator to the book handler window 2025-10-20 01:17:14 +03:00
Deniz Şafak ccb350fa08 Normalize platformpath 2025-10-19 17:17:40 +03:00
Deniz Şafak 42336145dd Fixed taskbar icon not appearing correctly in Windows 2025-10-19 17:13:29 +03:00
Deniz Şafak 5d2da7e91c Fixed book reprocessing issue 2025-10-19 16:37:34 +03:00
Deniz Şafak e69195e6ef Update changelog, fix wrong os import 2025-10-19 16:15:32 +03:00
Deniz Şafak 1520b971ac Fixed / and \ path display by normalizing paths 2025-10-19 16:02:36 +03:00
Deniz Şafak d426bac87e Fixed folder and filename sanitization to properly handle OS-specific illegal characters 2025-10-19 15:40:10 +03:00
Deniz Şafak 866746c5a1 Added Line option to subtitle generation modes 2025-10-08 00:18:39 +03:00
Deniz ŞafakandGitHub 58889d47b0 Merge pull request #94 from mleg/main
Add `Line` option for subtitle generation

Thank you, merged!
2025-10-08 00:13:40 +03:00
Deniz Şafak ae148eb591 Update readme 2025-10-07 23:56:17 +03:00
Deniz Şafak 14ed98c316 Update readme 2025-10-07 23:52:58 +03:00
Deniz Şafak eacbe05934 Fixed cannot access local variable 'is_narrow' error 2025-10-07 23:42:35 +03:00
Маслов Олег e7fb89f137 Add Line option for subtitle generation 2025-10-05 16:00:49 +03:00
9 changed files with 421 additions and 99 deletions
+11
View File
@@ -1,3 +1,14 @@
# 1.2.0
- Added `Line` option to subtitle generation modes, allowing subtitles to be generated based on line breaks in the text, by @mleg in #94.
- Added a loading indicator to the book handler window for better user experience during book preprocessing.
- Fixed `cannot access local variable 'is_narrow'` error when subtitle format `SRT` was selected, mentioned by @Kinasa0096 in #88.
- Fixed folder and filename sanitization to properly handle OS-specific illegal characters (Windows, Linux, macOS), ensuring compatibility across all platforms when creating chapter folders and files.
- Fixed `/` and `\` path display by normalizing paths.
- Fixed book reprocessing issue where books were being processed every time the chapters window was opened, improving performance when reopening the same book.
- Fixed taskbar icon not appearing correctly in Windows.
- Fixed “Go to folder” button not opening the chapter output directory when only separate chapters were generated.
- Improvements in code and documentation.
# 1.1.9 # 1.1.9
- Fixed the issue where spaces were deleted before punctuation marks while generating subtitles. - Fixed the issue where spaces were deleted before punctuation marks while generating subtitles.
- Fixed markdown TOC generation breaks when "Replace single newlines" is enabled. - Fixed markdown TOC generation breaks when "Replace single newlines" is enabled.
+37 -1
View File
@@ -98,6 +98,8 @@ pip3 install --pre torch torchvision torchaudio --index-url https://download.pyt
> See [How to fix "No matching distribution found" error?](#no-matching-distribution-found) > See [How to fix "No matching distribution found" error?](#no-matching-distribution-found)
> See [How to fix "[WinError 1114] A dynamic link library (DLL) initialization routine failed" error?](#WinError-1114)
> See [How to use "uv" instead of "pip"?](#use-uv-instead-of-pip) > See [How to use "uv" instead of "pip"?](#use-uv-instead-of-pip)
> Special thanks to [@hg000125](https://github.com/hg000125) for his contribution in [#23](https://github.com/denizsafak/abogen/issues/23). AMD GPU support is possible thanks to his work. > Special thanks to [@hg000125](https://github.com/hg000125) for his contribution in [#23](https://github.com/denizsafak/abogen/issues/23). AMD GPU support is possible thanks to his work.
@@ -136,7 +138,7 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
| **Select Voice** | First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. | | **Select Voice** | First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. |
| **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. | | **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. |
| **Voice preview** | Listen to the selected voice before processing. | | **Voice preview** | Listen to the selected voice before processing. |
| **Generate subtitles** | `Disabled`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) | | **Generate subtitles** | `Disabled`, `Line`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) |
| **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` | | **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` |
| **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. | | **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. |
| **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. | | **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. |
@@ -146,6 +148,8 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
> Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10) > Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10)
> Special thanks to [@mleg](https://github.com/mleg) for adding `Line` option in subtitle generation in PR [#94](https://github.com/denizsafak/abogen/pull/94)
| Book handler options | Description | | Book handler options | Description |
|---------|-------------| |---------|-------------|
| **Chapter Control** | Select specific `chapters` from ePUBs or markdown files or `chapters + pages` from PDFs. | | **Chapter Control** | Select specific `chapters` from ePUBs or markdown files or `chapters + pages` from PDFs. |
@@ -371,6 +375,22 @@ This will start Abogen in command-line mode and display detailed error messages.
</details> </details>
<details><summary><b>
<a name="WinError-1114">How to fix "[WinError 1114] A dynamic link library (DLL) initialization routine failed" error?</a>
</b></summary>
> I faced this error when trying to run Abogen in a virtual Windows machine without GPU support. Here's how I fixed it:
> If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, go to Abogen's folder (that contains `python_embedded`), open your terminal there and run:
> ```bash
> python_embedded\python.exe -m pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0
> ```
> If you installed Abogen using pip, open your terminal in the virtual environment and run:
> ```bash
> pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0
> ```
</details>
<details><summary><b> <details><summary><b>
<a name="japanese-audio-not-working">How to fix Japanese audio not working?</a> <a name="japanese-audio-not-working">How to fix Japanese audio not working?</a>
</b></summary> </b></summary>
@@ -392,6 +412,22 @@ This will start Abogen in command-line mode and display detailed error messages.
</details> </details>
<details><summary><b>
<a name="use-uv-instead-of-pip">How to uninstall Abogen?</a>
</b></summary>
> - From the settings menu, go to `Open configuration directory` and delete the directory.
> - From the settings menu, go to `Open cache directory` and delete the directory.
> - If you installed Abogen using pip, type:
>```bash
>pip uninstall abogen # uninstalls abogen
>pip cache purge # removes pip cache
>```
> - If you installed Abogen using the Windows installer (WINDOWS_INSTALL.bat), just remove the folder that contains Abogen. It installs everything inside `python_embedded` folder, no other directories are created.
> - If you installed espeak-ng, you need to remove it separately.
</details>
## `Contributing` ## `Contributing`
I welcome contributions! If you have ideas for new features, improvements, or bug fixes, please fork the repository and submit a pull request. I welcome contributions! If you have ideas for new features, improvements, or bug fixes, please fork the repository and submit a pull request.
### For developers and contributors ### For developers and contributors
+2 -2
View File
@@ -139,10 +139,10 @@ if exist "%VERSION_FILE%" (
REM Python embedded download configuration for different architectures REM Python embedded download configuration for different architectures
if "%PROCESSOR_ARCHITECTURE%"=="x86" ( if "%PROCESSOR_ARCHITECTURE%"=="x86" (
set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_win32.zip set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_win32.zip
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.8/python-3.12.8-embed-win32.zip set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.12/python-3.12.12-embed-win32.zip
) else ( ) else (
set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_amd64.zip set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_amd64.zip
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.8/python-3.12.8-embed-amd64.zip set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.12/python-3.12.12-embed-amd64.zip
) )
:: Check if Python exists :: Check if Python exists
+1 -1
View File
@@ -1 +1 @@
1.1.9 1.2.0
+181 -26
View File
@@ -1,5 +1,4 @@
import re import re
import html
import ebooklib import ebooklib
import base64 import base64
import fitz # PyMuPDF for PDF support import fitz # PyMuPDF for PDF support
@@ -21,7 +20,7 @@ from PyQt5.QtWidgets import (
QMenu, QMenu,
QLabel, QLabel,
) )
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt, QThread, pyqtSignal
from abogen.utils import clean_text, calculate_text_length, detect_encoding from abogen.utils import clean_text, calculate_text_length, detect_encoding
import os import os
import logging # Add logging import logging # Add logging
@@ -40,6 +39,38 @@ class HandlerDialog(QDialog):
_save_chapters_separately = False _save_chapters_separately = False
_merge_chapters_at_end = True _merge_chapters_at_end = True
_save_as_project = False # New class variable for save_as_project option _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)
_content_cache = {}
class _LoaderThread(QThread):
"""Minimal QThread that runs a callable and emits an error string on exception."""
error = pyqtSignal(str)
def __init__(self, target_callable):
super().__init__()
self._target = target_callable
def run(self):
try:
self._target()
except Exception as e:
self.error.emit(str(e))
@classmethod
def clear_content_cache(cls, book_path=None):
"""Clear the content cache. If book_path is provided, only clear that book's cache."""
if book_path is None:
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]
for key in keys_to_remove:
del cls._content_cache[key]
if keys_to_remove:
logging.info(f"Cleared content cache for {os.path.basename(book_path)}")
def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None): def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -139,14 +170,11 @@ class HandlerDialog(QDialog):
# Initialize checked_chapters set # Initialize checked_chapters set
self.checked_chapters = set(checked_chapters) if checked_chapters else set() self.checked_chapters = set(checked_chapters) if checked_chapters else set()
# For storing content and lengths # For storing content and lengths (will be filled by background loader)
self.content_texts = {} self.content_texts = {}
self.content_lengths = {} self.content_lengths = {}
# Pre-process content based on file type # Add a placeholder "Information" item so the tree isn't empty immediately
self._preprocess_content()
# Add "Information" item at the beginning of the tree
info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) info_item = QTreeWidgetItem(self.treeWidget, ["Information"])
info_item.setData(0, Qt.UserRole, "info:bookinfo") info_item.setData(0, Qt.UserRole, "info:bookinfo")
info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable) info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable)
@@ -154,8 +182,18 @@ class HandlerDialog(QDialog):
font.setBold(True) font.setBold(True)
info_item.setFont(0, font) info_item.setFont(0, font)
# Build tree based on file type # Setup UI now so dialog appears immediately
self._build_tree() self._setup_ui()
# 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:
self.splitter.setVisible(False)
self._show_loading_overlay("Loading...")
# Start background loading of book content so the dialog opens immediately
self._start_background_load()
# Hide expand/collapse decoration if there are no parent items # Hide expand/collapse decoration if there are no parent items
has_parents = False has_parents = False
@@ -165,30 +203,123 @@ class HandlerDialog(QDialog):
break break
self.treeWidget.setRootIsDecorated(has_parents) self.treeWidget.setRootIsDecorated(has_parents)
# Setup UI (creates save_chapters_checkbox and other UI elements)
self._setup_ui()
# Run auto-check after UI is setup
if not self._are_provided_checks_relevant():
self._run_auto_check()
# Connect signals def _create_loading_overlay(self):
self.treeWidget.currentItemChanged.connect(self.update_preview) """Create a simple centered QLabel used as loading indicator.
self.treeWidget.itemChanged.connect(self.handle_item_check)
self.treeWidget.itemChanged.connect(lambda _: self._update_checkbox_states())
self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click)
# Select first item and expand all This label is added to the dialog's main layout above the splitter so
self.treeWidget.expandAll() when the splitter is hidden only the text is visible.
if self.treeWidget.topLevelItemCount() > 0: """
self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0)) try:
self.treeWidget.setFocus() label = QLabel(self)
label.setAlignment(Qt.AlignCenter)
# larger plain text per request
label.setStyleSheet("font-size: 14pt;")
label.setVisible(False)
# insert at top of main layout if present, otherwise keep as child
try:
layout = self.layout()
if layout is not None:
layout.insertWidget(0, label)
except Exception:
pass
self._loading_label = label
except Exception:
self._loading_label = None
# Update checkbox states def _show_loading_overlay(self, text: str):
self._update_checkbox_states() lbl = getattr(self, '_loading_label', None)
if lbl is None:
return
lbl.setText(text)
lbl.setVisible(True)
def _hide_loading_overlay(self):
lbl = getattr(self, '_loading_label', None)
if lbl is None:
return
lbl.setVisible(False)
def _start_background_load(self):
"""Start a QThread that runs the preprocessing in background."""
# Start a minimal QThread which executes _preprocess_content
self._loader_thread = HandlerDialog._LoaderThread(self._preprocess_content)
self._loader_thread.finished.connect(self._on_load_finished)
self._loader_thread.error.connect(self._on_load_error)
# ensure thread instance is deleted when done
self._loader_thread.finished.connect(self._loader_thread.deleteLater)
self._loader_thread.start()
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:
self.previewEdit.setPlainText(f"Error loading book: {err_msg}")
if getattr(self, 'splitter', None) is not None:
self.splitter.setVisible(True)
self._hide_loading_overlay()
def _on_load_finished(self):
"""Called in the main thread when background loading finished."""
# Build the tree now that content_texts/content_lengths/etc. are ready
try:
# Rebuild tree based on file type
self._build_tree()
# Run auto-check if no provided checks are relevant
if not self._are_provided_checks_relevant():
self._run_auto_check()
# 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.itemDoubleClicked.connect(self.handle_item_double_click)
# Expand and select first item
self.treeWidget.expandAll()
if self.treeWidget.topLevelItemCount() > 0:
self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0))
self.treeWidget.setFocus()
# Update checkbox states
self._update_checkbox_states()
# Update preview for the current selection
current = self.treeWidget.currentItem()
self.update_preview(current)
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:
self.splitter.setVisible(True)
self._hide_loading_overlay()
def _preprocess_content(self): def _preprocess_content(self):
"""Pre-process content from the document""" """Pre-process content from the document"""
# Create cache key from file path, modification time, and file type
try:
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']
logging.info(f"Using cached content for {os.path.basename(self.book_path)}")
return
# Process content if not cached
if self.file_type == "epub": if self.file_type == "epub":
try: try:
self._process_epub_content_nav() # Use the new navigation-based method self._process_epub_content_nav() # Use the new navigation-based method
@@ -203,6 +334,19 @@ class HandlerDialog(QDialog):
self._preprocess_markdown_content() self._preprocess_markdown_content()
else: else:
self._preprocess_pdf_content() self._preprocess_pdf_content()
# Cache the processed content
cache_data = {
'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
HandlerDialog._content_cache[cache_key] = cache_data
logging.info(f"Cached content for {os.path.basename(self.book_path)}")
def _preprocess_pdf_content(self): def _preprocess_pdf_content(self):
"""Pre-process all page contents from PDF document""" """Pre-process all page contents from PDF document"""
@@ -1906,6 +2050,17 @@ class HandlerDialog(QDialog):
return metadata return metadata
def get_selected_text(self): def get_selected_text(self):
# If a background loader thread is running, wait for it to finish to
# 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:
# Wait for thread to finish (blocks until done)
if self._loader_thread.isRunning():
self._loader_thread.wait()
except Exception:
pass
if self.file_type == "epub": if self.file_type == "epub":
return self._get_epub_selected_text() return self._get_epub_selected_text()
elif self.file_type == "markdown": elif self.file_type == "markdown":
+112 -25
View File
@@ -28,6 +28,66 @@ def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
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)
# Remove control characters (0-31)
sanitized = re.sub(r'[\x00-\x1f]', '_', sanitized)
# Remove trailing spaces and dots
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:
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)
# Remove control characters
sanitized = re.sub(r'[\x00-\x1f]', '_', sanitized)
# Avoid leading dot for folders (creates hidden folders)
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)
# Remove other control characters for safety
sanitized = re.sub(r'[\x01-\x1f]', '_', sanitized)
# Avoid leading dot for folders (creates hidden folders)
if is_folder and sanitized.startswith('.'):
sanitized = '_' + sanitized[1:]
# Ensure the name is not empty after sanitization
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('. ')
return sanitized
class ChapterOptionsDialog(QDialog): class ChapterOptionsDialog(QDialog):
def __init__(self, chapter_count, parent=None): def __init__(self, chapter_count, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -248,6 +308,10 @@ class ConversionThread(QThread):
input_file = self.display_path if self.display_path else self.file_name input_file = self.display_path if self.display_path else self.file_name
processing_file = self.file_name processing_file = self.file_name
# 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
self.log_updated.emit(f"- Input File: {input_file}") self.log_updated.emit(f"- Input File: {input_file}")
if input_file != processing_file: if input_file != processing_file:
self.log_updated.emit(f"- Processing File: {processing_file}") self.log_updated.emit(f"- Processing File: {processing_file}")
@@ -417,6 +481,9 @@ class ConversionThread(QThread):
base_path = self.display_path if self.display_path else self.file_name base_path = self.display_path if self.display_path else self.file_name
base_name = os.path.splitext(os.path.basename(base_path))[0] 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": if self.save_option == "Save to Desktop":
parent_dir = user_desktop_dir() parent_dir = user_desktop_dir()
elif self.save_option == "Save next to input file": elif self.save_option == "Save next to input file":
@@ -437,11 +504,11 @@ class ConversionThread(QThread):
while True: while True:
suffix = f"_{counter}" if counter > 1 else "" suffix = f"_{counter}" if counter > 1 else ""
chapters_out_dir_candidate = os.path.join( chapters_out_dir_candidate = os.path.join(
parent_dir, f"{base_name}{suffix}_chapters" parent_dir, f"{sanitized_base_name}{suffix}_chapters"
) )
# Only check for files with allowed extensions (extension without dot, case-insensitive) # Only check for files with allowed extensions (extension without dot, case-insensitive)
clash = any( clash = any(
os.path.splitext(fname)[0] == f"{base_name}{suffix}" os.path.splitext(fname)[0] == f"{sanitized_base_name}{suffix}"
and os.path.splitext(fname)[1][1:].lower() in allowed_exts and os.path.splitext(fname)[1][1:].lower() in allowed_exts
for fname in os.listdir(parent_dir) for fname in os.listdir(parent_dir)
) )
@@ -461,7 +528,7 @@ class ConversionThread(QThread):
# Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True # Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True
if merge_chapters_at_end: if merge_chapters_at_end:
out_dir = parent_dir out_dir = parent_dir
base_filepath_no_ext = os.path.join(out_dir, f"{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}" merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
subtitle_entries = [] subtitle_entries = []
current_time = 0.0 current_time = 0.0
@@ -554,6 +621,13 @@ class ConversionThread(QThread):
merged_subtitle_path = ( merged_subtitle_path = (
os.path.splitext(merged_out_path)[0] + f".{file_extension}" os.path.splitext(merged_out_path)[0] + f".{file_extension}"
) )
# Default subtitle layout flags/strings so they exist regardless
# of whether ASS-specific handling runs. This prevents runtime
# errors when non-ASS formats (like SRT) are selected.
is_centered = False
is_narrow = False
merged_subtitle_margin = ""
merged_subtitle_alignment_tag = ""
if "ass" in subtitle_format: if "ass" in subtitle_format:
merged_subtitle_file = open( merged_subtitle_file = open(
merged_subtitle_path, merged_subtitle_path,
@@ -644,8 +718,13 @@ class ConversionThread(QThread):
loaded_voice = self.voice loaded_voice = self.voice
# Prepare per-chapter output file if needed # Prepare per-chapter output file if needed
if save_chapters_separately and total_chapters > 1: if save_chapters_separately and total_chapters > 1:
# First pass: keep alphanumeric, spaces, hyphens, and underscores
sanitized = re.sub(r"[^\w\s\-]", "", chapter_name) sanitized = re.sub(r"[^\w\s\-]", "", chapter_name)
# Replace multiple spaces/hyphens with single underscore
sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_") sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_")
# Apply OS-specific sanitization
sanitized = sanitize_name_for_os(sanitized, is_folder=False)
# Limit length (leaving room for the chapter number prefix)
MAX_LEN = 80 MAX_LEN = 80
if len(sanitized) > MAX_LEN: if len(sanitized) > MAX_LEN:
pos = sanitized[:MAX_LEN].rfind("_") pos = sanitized[:MAX_LEN].rfind("_")
@@ -705,13 +784,20 @@ class ConversionThread(QThread):
chapter_subtitle_path = os.path.join( chapter_subtitle_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{file_extension}" chapters_out_dir, f"{chapter_filename}.{file_extension}"
) )
# Ensure these variables exist even when not using ASS so
# later code can safely reference them.
is_centered = False
is_narrow = False
chapter_subtitle_margin = ""
chapter_subtitle_alignment_tag = ""
# Open the chapter subtitle file for writing for both SRT and ASS
chapter_subtitle_file = open(
chapter_subtitle_path,
"w",
encoding="utf-8",
errors="replace",
)
if "ass" in subtitle_format: if "ass" in subtitle_format:
chapter_subtitle_file = open(
chapter_subtitle_path,
"w",
encoding="utf-8",
errors="replace",
)
# Minimal ASS header # Minimal ASS header
chapter_subtitle_file.write("[Script Info]\n") chapter_subtitle_file.write("[Script Info]\n")
chapter_subtitle_file.write("Title: Generated by Abogen\n") chapter_subtitle_file.write("Title: Generated by Abogen\n")
@@ -734,18 +820,13 @@ class ConversionThread(QThread):
is_narrow = subtitle_format in ( is_narrow = subtitle_format in (
"ass_narrow", "ass_narrow",
"ass_centered_narrow", "ass_centered_narrow",
) )
chapter_subtitle_margin = "90" if is_narrow else "" chapter_subtitle_margin = "90" if is_narrow else ""
chapter_subtitle_alignment_tag = ( chapter_subtitle_alignment_tag = (
f"{{\\an5}}" if is_centered else "" f"{{\\an5}}" if is_centered else ""
) )
else: else:
chapter_subtitle_file = open( chapter_subtitle_file = None
chapter_subtitle_path,
"w",
encoding="utf-8",
errors="replace",
)
else: else:
chapter_subtitle_path = None chapter_subtitle_path = None
chapter_subtitle_file = None chapter_subtitle_file = None
@@ -1036,11 +1117,12 @@ class ConversionThread(QThread):
merged_out_path, merged_out_path,
) )
else: else:
# If not merging, just finish after chapters # If not merging, report the folder that holds the chapter files
self.progress_updated.emit(100, "00:00:00") self.progress_updated.emit(100, "00:00:00")
chapters_dir = os.path.abspath(chapters_out_dir or parent_dir)
self.conversion_finished.emit( self.conversion_finished.emit(
("\nAll chapters saved.", "green"), (f"\nAll chapters saved to: {chapters_dir}", "green"),
None, chapters_dir,
) )
except Exception as e: except Exception as e:
# Cleanup ffmpeg subprocesses on error # Cleanup ffmpeg subprocesses on error
@@ -1246,9 +1328,14 @@ class ConversionThread(QThread):
if end is None or end <= start or end <= 0: if end is None or end <= start or end <= 0:
subtitle_entries[-1] = (start, fallback_end_time, text) subtitle_entries[-1] = (start, fallback_end_time, text)
elif self.subtitle_mode == "Sentence" or self.subtitle_mode == "Sentence + Comma": elif self.subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]:
# Define separator pattern based on mode # Define separator pattern based on mode
separator = r"[.!?]" if self.subtitle_mode == "Sentence" else r"[.!?,]" if self.subtitle_mode == "Line":
separator = r"\n"
elif self.subtitle_mode == "Sentence":
separator = r"[.!?]"
else: # Sentence + Comma
separator = r"[.!?,]"
current_sentence = [] current_sentence = []
word_count = 0 word_count = 0
+57 -35
View File
@@ -85,7 +85,6 @@ from abogen.voice_profiles import load_profiles
# Import ctypes for Windows-specific taskbar icon # Import ctypes for Windows-specific taskbar icon
if platform.system() == "Windows": if platform.system() == "Windows":
import ctypes import ctypes
import winreg
class DarkTitleBarEventFilter(QObject): class DarkTitleBarEventFilter(QObject):
@@ -231,6 +230,8 @@ class InputBox(QLabel):
size_str = self._human_readable_size(os.path.getsize(file_path)) size_str = self._human_readable_size(os.path.getsize(file_path))
name = os.path.basename(file_path) name = os.path.basename(file_path)
char_count = 0 char_count = 0
window = self.window()
cache = getattr(window, "_char_count_cache", None)
def parse_size(size_str): def parse_size(size_str):
# Use regex to extract the numeric part # Use regex to extract the numeric part
@@ -250,40 +251,49 @@ class InputBox(QLabel):
except Exception: except Exception:
return str(n) return str(n)
if ( doc_extensions = (".epub", ".pdf", ".md", ".markdown")
file_path.lower().endswith(".epub") char_source_path = file_path
or file_path.lower().endswith(".pdf") cached_char_count = None
or file_path.lower().endswith((".md", ".markdown"))
) and hasattr(self.window(), "selected_chapters"):
# EPUB, PDF, or Markdown: sum character counts for selected chapters
try:
book_path = file_path if file_path.lower().endswith(doc_extensions):
dialog = HandlerDialog( selected_file_path = getattr(window, "selected_file", None)
book_path, if selected_file_path and os.path.exists(selected_file_path):
checked_chapters=self.window().selected_chapters, char_source_path = selected_file_path
parent=self.window(), else:
) char_source_path = None
chapters_text, all_checked_hrefs = dialog.get_selected_text()
# Clean text before counting characters if cache is not None:
cleaned_text = clean_text(chapters_text) cached_char_count = cache.get(file_path)
char_count = calculate_text_length(cleaned_text) if (
except Exception: cached_char_count is None
char_count = "N/A" and char_source_path
else: and char_source_path != file_path
):
cached_char_count = cache.get(char_source_path)
if cached_char_count is not None:
char_count = cached_char_count
elif char_source_path:
try: try:
with open(file_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() text = f.read()
# Clean text before counting characters
cleaned_text = clean_text(text) cleaned_text = clean_text(text)
char_count = calculate_text_length(cleaned_text) char_count = calculate_text_length(cleaned_text)
except Exception: except Exception:
char_count = "N/A" char_count = "N/A"
else:
char_count = "N/A"
if cache is not None and isinstance(char_count, int):
cache[file_path] = char_count
if char_source_path and char_source_path != file_path:
cache[char_source_path] = char_count
# Store numeric char_count on window # Store numeric char_count on window
try: try:
self.window().char_count = int(char_count) window.char_count = int(char_count)
except Exception: except Exception:
self.window().char_count = 0 window.char_count = 0
# embed icon at native size with word-wrap for the filename # embed icon at native size with word-wrap for the filename
self.setText( self.setText(
f'<img src="data:image/png;base64,{img_data}"><br><span style="display: inline-block; max-width: 100%; word-break: break-all;"><b>{name}</b></span><br>Size: {size_str}<br>Characters: {format_num(char_count)}' f'<img src="data:image/png;base64,{img_data}"><br><span style="display: inline-block; max-width: 100%; word-break: break-all;"><b>{name}</b></span><br>Size: {size_str}<br>Characters: {format_num(char_count)}'
@@ -294,11 +304,11 @@ class InputBox(QLabel):
f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}"
) )
self.clear_btn.show() self.clear_btn.show()
is_document = self.window().selected_file_type in ["epub", "pdf", "md", "markdown"] is_document = window.selected_file_type in ["epub", "pdf", "md", "markdown"]
self.chapters_btn.setVisible(is_document) self.chapters_btn.setVisible(is_document)
if is_document: if is_document:
chapter_count = len(self.window().selected_chapters) chapter_count = len(window.selected_chapters)
file_type = self.window().selected_file_type file_type = window.selected_file_type
# Adjust button text based on file type # Adjust button text based on file type
if file_type == "epub" or file_type == "md" or file_type == "markdown": if file_type == "epub" or file_type == "md" or file_type == "markdown":
self.chapters_btn.setText(f"Chapters ({chapter_count})") self.chapters_btn.setText(f"Chapters ({chapter_count})")
@@ -313,8 +323,8 @@ class InputBox(QLabel):
# For epub/pdf files, show edit if we have a selected_file (temp txt) # For epub/pdf files, show edit if we have a selected_file (temp txt)
if ( if (
self.window().selected_file_type in ["epub", "pdf", "md", "markdown", "md", "markdown"] window.selected_file_type in ["epub", "pdf", "md", "markdown", "md", "markdown"]
and self.window().selected_file and window.selected_file
): ):
should_show_edit = True should_show_edit = True
@@ -322,13 +332,13 @@ class InputBox(QLabel):
self.go_to_folder_btn.show() self.go_to_folder_btn.show()
# Enable add to queue button only when file is accepted (input box is green) # Enable add to queue button only when file is accepted (input box is green)
self.resizeEvent(None) self.resizeEvent(None)
if hasattr(self.window(), "btn_add_to_queue"): if hasattr(window, "btn_add_to_queue"):
self.window().btn_add_to_queue.setEnabled(True) window.btn_add_to_queue.setEnabled(True)
self.chapters_btn.adjustSize() self.chapters_btn.adjustSize()
# Reset the input_box_cleared_by_queue flag after setting file info # Reset the input_box_cleared_by_queue flag after setting file info
if hasattr(self.window(), "input_box_cleared_by_queue"): if hasattr(window, "input_box_cleared_by_queue"):
self.window().input_box_cleared_by_queue = False window.input_box_cleared_by_queue = False
def set_error(self, message): def set_error(self, message):
self.setText(message) self.setText(message)
@@ -721,6 +731,8 @@ class abogen(QWidget):
self.selected_chapters = set() self.selected_chapters = set()
self.last_opened_book_path = None # Track the last opened book path self.last_opened_book_path = None # Track the last opened book path
self.last_output_path = None self.last_output_path = None
self.char_count = 0
self._char_count_cache = {}
# Only one of selected_profile_name or selected_voice should be set # Only one of selected_profile_name or selected_voice should be set
self.selected_profile_name = self.config.get("selected_profile_name") self.selected_profile_name = self.config.get("selected_profile_name")
self.selected_voice = None self.selected_voice = None
@@ -953,6 +965,7 @@ class abogen(QWidget):
self.subtitle_combo.setToolTip( self.subtitle_combo.setToolTip(
"Choose how subtitles will be generated:\n" "Choose how subtitles will be generated:\n"
"Disabled: No subtitles will be generated.\n" "Disabled: No subtitles will be generated.\n"
"Line: Subtitles will be generated for each line.\n"
"Sentence: Subtitles will be generated for each sentence.\n" "Sentence: Subtitles will be generated for each sentence.\n"
"Sentence + Comma: Subtitles will be generated for each sentence and comma.\n" "Sentence + Comma: Subtitles will be generated for each sentence and comma.\n"
"Sentence + Highlighting: Subtitles with word-by-word karaoke highlighting.\n" "Sentence + Highlighting: Subtitles with word-by-word karaoke highlighting.\n"
@@ -963,7 +976,7 @@ class abogen(QWidget):
for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
) )
) )
subtitle_options = ["Disabled", "Sentence", "Sentence + Comma", "Sentence + Highlighting"] + [ 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) 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.addItems(subtitle_options)
@@ -1254,6 +1267,7 @@ class abogen(QWidget):
self.selected_chapters = set() self.selected_chapters = set()
self.last_opened_book_path = book_path self.last_opened_book_path = book_path
# HandlerDialog uses internal caching to avoid reprocessing the same book
dialog = HandlerDialog( dialog = HandlerDialog(
book_path, book_path,
file_type=getattr(self, 'selected_file_type', None), file_type=getattr(self, 'selected_file_type', None),
@@ -1292,6 +1306,12 @@ class abogen(QWidget):
if book_path.lower().endswith(".pdf"): if book_path.lower().endswith(".pdf"):
self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False) self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False)
cleaned_text = clean_text(chapters_text)
computed_char_count = calculate_text_length(cleaned_text)
self.char_count = computed_char_count
if isinstance(getattr(self, "_char_count_cache", None), dict):
self._char_count_cache[book_path] = computed_char_count
# Use "abogen" prefix for cache files # Use "abogen" prefix for cache files
# Extract base name without extension # Extract base name without extension
base_name = os.path.splitext(os.path.basename(book_path))[0] base_name = os.path.splitext(os.path.basename(book_path))[0]
@@ -1376,6 +1396,8 @@ class abogen(QWidget):
self.selected_file = tmp self.selected_file = tmp
self.selected_book_path = book_path self.selected_book_path = book_path
self.displayed_file_path = book_path self.displayed_file_path = book_path
if isinstance(getattr(self, "_char_count_cache", None), dict):
self._char_count_cache[tmp] = computed_char_count
# Only set file info if dialog was accepted # Only set file info if dialog was accepted
self.input_box.set_file_info(book_path) self.input_box.set_file_info(book_path)
return True return True
+14 -8
View File
@@ -9,7 +9,10 @@ import signal
try: try:
from PyQt5.QtCore import QLibraryInfo from PyQt5.QtCore import QLibraryInfo
plugins = QLibraryInfo.location(QLibraryInfo.PluginsPath) plugins = QLibraryInfo.location(QLibraryInfo.PluginsPath)
platform_dir = os.path.join(plugins, "platforms") # 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): if os.path.isdir(platform_dir):
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir
print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir) print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir)
@@ -18,6 +21,16 @@ try:
except ImportError: except ImportError:
print("PyQt5 not installed.") print("PyQt5 not installed.")
# Set application ID for Windows taskbar icon
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:
print("Warning: failed to set AppUserModelID:", e)
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
@@ -86,13 +99,6 @@ def qt_message_handler(mode, context, message):
# Install the custom message handler # Install the custom message handler
qInstallMessageHandler(qt_message_handler) qInstallMessageHandler(qt_message_handler)
# Set application ID for Windows taskbar icon
if platform.system() == "Windows":
import ctypes
app_id = f"{PROGRAM_NAME}.{VERSION}"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
# Handle Wayland on Linux GNOME # Handle Wayland on Linux GNOME
if platform.system() == "Linux": if platform.system() == "Linux":
xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower() xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower()
+6 -1
View File
@@ -207,6 +207,8 @@ class QueueManager(QDialog):
def process_queue(self): def process_queue(self):
"""Process the queue items.""" """Process the queue items."""
import os
self.listwidget.clear() self.listwidget.clear()
if not self.queue: if not self.queue:
self.empty_overlay.resize(self.listwidget.size()) self.empty_overlay.resize(self.listwidget.size())
@@ -221,9 +223,12 @@ class QueueManager(QDialog):
display_file_path = getattr(item, "save_base_path", None) or item.file_name display_file_path = getattr(item, "save_base_path", None) or item.file_name
processing_file_path = 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
# Only show the file name, not the full path # Only show the file name, not the full path
display_name = display_file_path display_name = display_file_path
import os
if os.path.sep in display_file_path: if os.path.sep in display_file_path:
display_name = os.path.basename(display_file_path) display_name = os.path.basename(display_file_path)