mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d1def330 | ||
|
|
4a28bfb859 | ||
|
|
4adda262c0 | ||
|
|
1d02802172 | ||
|
|
28c0b2c401 | ||
|
|
1e89b566d1 | ||
|
|
ccb350fa08 | ||
|
|
42336145dd | ||
|
|
5d2da7e91c | ||
|
|
e69195e6ef | ||
|
|
1520b971ac | ||
|
|
d426bac87e | ||
|
|
866746c5a1 | ||
|
|
58889d47b0 | ||
|
|
ae148eb591 | ||
|
|
14ed98c316 | ||
|
|
eacbe05934 | ||
|
|
e7fb89f137 |
@@ -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.
|
||||||
|
|||||||
@@ -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 @@ Here’s 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 @@ Here’s 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
@@ -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
@@ -1 +1 @@
|
|||||||
1.1.9
|
1.2.0
|
||||||
+169
-14
@@ -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
|
||||||
@@ -41,6 +40,38 @@ class HandlerDialog(QDialog):
|
|||||||
_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,20 +203,81 @@ 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
|
|
||||||
|
def _create_loading_overlay(self):
|
||||||
|
"""Create a simple centered QLabel used as loading indicator.
|
||||||
|
|
||||||
|
This label is added to the dialog's main layout above the splitter so
|
||||||
|
when the splitter is hidden only the text is visible.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
def _show_loading_overlay(self, text: str):
|
||||||
|
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():
|
if not self._are_provided_checks_relevant():
|
||||||
self._run_auto_check()
|
self._run_auto_check()
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals (after tree exists)
|
||||||
self.treeWidget.currentItemChanged.connect(self.update_preview)
|
self.treeWidget.currentItemChanged.connect(self.update_preview)
|
||||||
self.treeWidget.itemChanged.connect(self.handle_item_check)
|
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)
|
self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click)
|
||||||
|
|
||||||
# Select first item and expand all
|
# Expand and select first item
|
||||||
self.treeWidget.expandAll()
|
self.treeWidget.expandAll()
|
||||||
if self.treeWidget.topLevelItemCount() > 0:
|
if self.treeWidget.topLevelItemCount() > 0:
|
||||||
self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0))
|
self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0))
|
||||||
@@ -187,8 +286,40 @@ class HandlerDialog(QDialog):
|
|||||||
# Update checkbox states
|
# Update checkbox states
|
||||||
self._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
|
||||||
@@ -204,6 +335,19 @@ class HandlerDialog(QDialog):
|
|||||||
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"""
|
||||||
for page_num in range(len(self.pdf_doc)):
|
for page_num in range(len(self.pdf_doc)):
|
||||||
@@ -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":
|
||||||
|
|||||||
+102
-15
@@ -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}"
|
||||||
)
|
)
|
||||||
if "ass" in subtitle_format:
|
# 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_file = open(
|
||||||
chapter_subtitle_path,
|
chapter_subtitle_path,
|
||||||
"w",
|
"w",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
)
|
)
|
||||||
|
if "ass" in subtitle_format:
|
||||||
# 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")
|
||||||
@@ -740,12 +826,7 @@ class ConversionThread(QThread):
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
+56
-34
@@ -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(),
|
|
||||||
)
|
|
||||||
chapters_text, all_checked_hrefs = dialog.get_selected_text()
|
|
||||||
# Clean text before counting characters
|
|
||||||
cleaned_text = clean_text(chapters_text)
|
|
||||||
char_count = calculate_text_length(cleaned_text)
|
|
||||||
except Exception:
|
|
||||||
char_count = "N/A"
|
|
||||||
else:
|
else:
|
||||||
|
char_source_path = None
|
||||||
|
|
||||||
|
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 = 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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user