mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Merge branch 'denizsafak:main' into main
This commit is contained in:
+6
-1
@@ -1 +1,6 @@
|
|||||||
- Fixed "'utf-8' codec can't decode byte" error, mentioned in #3 by @nigelp
|
- Enhanced EPUB handling by treating all items in chapter list (including anchors) as chapters, improving navigation and organization for poorly structured books, mentioned by @Darthagnon in #4
|
||||||
|
- Fixed the issue with some chapters in EPUB files had missing content.
|
||||||
|
- Fixed the issue with some EPUB files only having one chapter caused the program to ignore the entire book.
|
||||||
|
- Fixed "utf-8' codec can't decode byte" error, mentioned by @nigelp in #3
|
||||||
|
- Added "Replace single newlines with spaces" option in the menu. This can be useful for texts that have imaginary line breaks.
|
||||||
|
- Improvements in code and documentation.
|
||||||
@@ -56,7 +56,7 @@ sudo dnf install espeak-ng
|
|||||||
# Install abogen
|
# Install abogen
|
||||||
pip install abogen
|
pip install abogen
|
||||||
```
|
```
|
||||||
> If you get "No matching distribution found" error, try installing it on supported Python (3.10 to 3.12). You can use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily in Linux.
|
> If you get "No matching distribution found" error, try installing it on supported Python (3.10 to 3.12). You can use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily in Linux. Watch this [video](https://www.youtube.com/watch?v=MVyb-nI4KyI) by NetworkChuck for a quick guide.
|
||||||
|
|
||||||
Then simply run by typing:
|
Then simply run by typing:
|
||||||
|
|
||||||
@@ -88,6 +88,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
|
|||||||
- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
|
- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
|
||||||
- **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs.
|
- **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs.
|
||||||
- **Options**:
|
- **Options**:
|
||||||
|
- **Replace single newlines with spaces**: Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks.
|
||||||
- **Configure max words per subtitle**: Automatically configures the maximum number of words per subtitle entry.
|
- **Configure max words per subtitle**: Automatically configures the maximum number of words per subtitle entry.
|
||||||
- **Create desktop shortcut**: Creates a shortcut on your desktop for easy access.
|
- **Create desktop shortcut**: Creates a shortcut on your desktop for easy access.
|
||||||
- **Open config.json directory**: Opens the directory where the configuration file is stored.
|
- **Open config.json directory**: Opens the directory where the configuration file is stored.
|
||||||
@@ -117,6 +118,11 @@ keep-open=yes
|
|||||||
--audio-device=openal
|
--audio-device=openal
|
||||||
--sub-margin-x=235
|
--sub-margin-x=235
|
||||||
--sub-pos=60
|
--sub-pos=60
|
||||||
|
# --- Audio Quality ---
|
||||||
|
audio-spdif=ac3,dts,eac3,truehd,dts-hd
|
||||||
|
audio-channels=auto
|
||||||
|
audio-samplerate=48000
|
||||||
|
volume-max=200
|
||||||
```
|
```
|
||||||
|
|
||||||
## `Similar Projects`
|
## `Similar Projects`
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.1
|
1.0.2
|
||||||
+265
-108
@@ -19,6 +19,7 @@ from PyQt5.QtWidgets import (
|
|||||||
QPushButton,
|
QPushButton,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QMenu,
|
QMenu,
|
||||||
|
QLabel,
|
||||||
)
|
)
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
from utils import clean_text, calculate_text_length
|
from utils import clean_text, calculate_text_length
|
||||||
@@ -128,74 +129,234 @@ class HandlerDialog(QDialog):
|
|||||||
def _preprocess_content(self):
|
def _preprocess_content(self):
|
||||||
"""Pre-process content from the document"""
|
"""Pre-process content from the document"""
|
||||||
if self.file_type == "epub":
|
if self.file_type == "epub":
|
||||||
self._preprocess_epub_content()
|
# Always process EPUB content using the anchor-based approach
|
||||||
|
self._process_epub_content()
|
||||||
else:
|
else:
|
||||||
self._preprocess_pdf_content()
|
self._preprocess_pdf_content()
|
||||||
|
|
||||||
def _preprocess_epub_content(self):
|
def _process_epub_content(self, split_anchors=True):
|
||||||
"""Extract EPUB content with preserved formatting."""
|
"""
|
||||||
book, spine = self.book, [
|
Process EPUB content by globally ordering TOC entries and slicing content between them.
|
||||||
item.get_name()
|
Ensures all content between defined TOC start points is captured.
|
||||||
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT)
|
"""
|
||||||
]
|
# split_anchors parameter kept for compatibility but always treated as True
|
||||||
|
book = self.book
|
||||||
|
|
||||||
|
# 1. Cache all document HTML and determine spine order
|
||||||
|
self.doc_content = {}
|
||||||
|
# Correctly get hrefs from spine items
|
||||||
|
spine_docs = []
|
||||||
|
for spine_item_tuple in book.spine:
|
||||||
|
item_id = spine_item_tuple[0]
|
||||||
|
item = book.get_item_with_id(item_id)
|
||||||
|
if item:
|
||||||
|
spine_docs.append(item.get_name()) # Use get_name() for href
|
||||||
|
else:
|
||||||
|
print(f"Warning: Spine item with id '{item_id}' not found in book items.")
|
||||||
|
|
||||||
|
doc_order = {href: i for i, href in enumerate(spine_docs)}
|
||||||
|
|
||||||
|
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||||
|
href = item.get_name()
|
||||||
|
if href in doc_order: # Only process docs in spine
|
||||||
|
try:
|
||||||
|
html_content = item.get_content().decode('utf-8', errors='ignore')
|
||||||
|
self.doc_content[href] = html_content
|
||||||
|
except Exception:
|
||||||
|
self.doc_content[href] = "" # Handle decoding errors
|
||||||
|
|
||||||
|
# 2. Get all TOC entries with hrefs and determine their positions
|
||||||
|
toc_entries_with_pos = []
|
||||||
|
|
||||||
|
def find_position(doc_href, fragment_id):
|
||||||
|
if doc_href not in self.doc_content:
|
||||||
|
return -1
|
||||||
|
html_content = self.doc_content[doc_href]
|
||||||
|
if not fragment_id: # No fragment, position is 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Find position of fragment identifier (id= or name=)
|
||||||
|
id_match_str = f'id="{fragment_id}"'
|
||||||
|
name_match_str = f'name="{fragment_id}"'
|
||||||
|
|
||||||
|
id_pos = html_content.find(id_match_str)
|
||||||
|
name_pos = html_content.find(name_match_str)
|
||||||
|
|
||||||
|
pos = -1
|
||||||
|
if id_pos != -1 and name_pos != -1:
|
||||||
|
pos = min(id_pos, name_pos)
|
||||||
|
elif id_pos != -1:
|
||||||
|
pos = id_pos
|
||||||
|
elif name_pos != -1:
|
||||||
|
pos = name_pos
|
||||||
|
else:
|
||||||
|
return -1 # Anchor not found by simple string search
|
||||||
|
|
||||||
|
# Backtrack to the start of the tag '<'
|
||||||
|
tag_start_pos = html_content.rfind('<', 0, pos)
|
||||||
|
return tag_start_pos if tag_start_pos != -1 else 0 # Default to 0 if '<' not found
|
||||||
|
|
||||||
|
def collect_toc_entries(entries):
|
||||||
|
collected = []
|
||||||
|
for entry in entries:
|
||||||
|
href, title = None, "Unknown"
|
||||||
|
children = []
|
||||||
|
entry_obj = None # Store the original entry object
|
||||||
|
|
||||||
# Get TOC entries with titles
|
|
||||||
toc = []
|
|
||||||
for entry in book.toc:
|
|
||||||
if isinstance(entry, ebooklib.epub.Link):
|
if isinstance(entry, ebooklib.epub.Link):
|
||||||
toc.append((entry.href.split("#")[0], entry.title))
|
href, title = entry.href, entry.title or entry.href
|
||||||
elif isinstance(entry, tuple) and hasattr(entry[0], "href"):
|
entry_obj = entry
|
||||||
href = getattr(entry[0], "href", None)
|
elif isinstance(entry, tuple) and len(entry) >= 1:
|
||||||
|
section_or_link = entry[0]
|
||||||
|
entry_obj = section_or_link
|
||||||
|
if isinstance(section_or_link, ebooklib.epub.Section):
|
||||||
|
title = section_or_link.title
|
||||||
|
href = getattr(section_or_link, "href", None)
|
||||||
|
elif isinstance(section_or_link, ebooklib.epub.Link):
|
||||||
|
href, title = section_or_link.href, section_or_link.title or section_or_link.href
|
||||||
|
|
||||||
|
if len(entry) > 1 and isinstance(entry[1], list):
|
||||||
|
children = entry[1]
|
||||||
|
|
||||||
if href:
|
if href:
|
||||||
toc.append(
|
base_href, fragment = href.split('#', 1) if '#' in href else (href, None)
|
||||||
(
|
if base_href in doc_order: # Only consider entries pointing to spine documents
|
||||||
href.split("#")[0],
|
position = find_position(base_href, fragment)
|
||||||
entry[0].title if hasattr(entry[0], "title") else None,
|
if position != -1: # Only add if position is valid
|
||||||
)
|
collected.append({
|
||||||
)
|
"href": href, # Use the original href from TOC as the key
|
||||||
|
"title": title,
|
||||||
|
"doc_href": base_href,
|
||||||
|
"position": position,
|
||||||
|
"doc_order": doc_order[base_href]
|
||||||
|
})
|
||||||
|
|
||||||
# Find chapter ranges and process content
|
if children:
|
||||||
toc = [(h, t) for h, t in toc if h in spine and t]
|
collected.extend(collect_toc_entries(children))
|
||||||
chapter_indices = [spine.index(h) for h, _ in toc if h in spine] + [len(spine)]
|
return collected
|
||||||
|
|
||||||
def process_html(item):
|
all_toc_entries = collect_toc_entries(self.book.toc)
|
||||||
if not item:
|
|
||||||
return ""
|
|
||||||
soup = BeautifulSoup(item.get_content(), "html.parser")
|
|
||||||
for br in soup.find_all("br"):
|
|
||||||
br.replace_with("\n")
|
|
||||||
for p in soup.find_all(["p", "div"]):
|
|
||||||
p.append("\n\n")
|
|
||||||
return re.sub(r"\n{3,}", "\n\n", clean_text(soup.get_text())).strip()
|
|
||||||
|
|
||||||
# Process chapters
|
# Handle case where book has no TOC or empty TOC
|
||||||
for i in range(len(toc)):
|
if not all_toc_entries:
|
||||||
href, title = toc[i]
|
# Create a synthetic TOC entry for the first spine document
|
||||||
start, end = chapter_indices[i], chapter_indices[i + 1]
|
if spine_docs:
|
||||||
|
# Process all content as a single chapter
|
||||||
|
all_content_html = ""
|
||||||
|
for doc_href in spine_docs:
|
||||||
|
all_content_html += self.doc_content.get(doc_href, "")
|
||||||
|
|
||||||
texts = [
|
if all_content_html:
|
||||||
process_html(book.get_item_with_href(spine[idx]))
|
soup = BeautifulSoup(all_content_html, 'html.parser')
|
||||||
for idx in range(start, end)
|
text = clean_text(soup.get_text()).strip()
|
||||||
]
|
|
||||||
texts = [t for t in texts if t]
|
|
||||||
|
|
||||||
full_text = "\n\n".join(texts)
|
# Use the first spine document as the identifier
|
||||||
if title and full_text.lower().startswith(title.lower()):
|
first_doc = spine_docs[0]
|
||||||
full_text = (
|
self.content_texts[first_doc] = text
|
||||||
full_text[: len(title)].rstrip()
|
self.content_lengths[first_doc] = len(text)
|
||||||
+ "\n\n"
|
|
||||||
+ full_text[len(title) :].lstrip()
|
|
||||||
)
|
|
||||||
|
|
||||||
self.content_texts[href] = full_text
|
# Create a synthetic TOC entry for tree building
|
||||||
self.content_lengths[href] = calculate_text_length(full_text)
|
self.book.toc = [(epub.Link(first_doc, "Main Content", first_doc),)]
|
||||||
|
return
|
||||||
|
|
||||||
# Process any spine items not in TOC
|
# 3. Sort TOC entries globally
|
||||||
for href in spine:
|
all_toc_entries.sort(key=lambda x: (x["doc_order"], x["position"]))
|
||||||
if href not in self.content_texts:
|
|
||||||
text = process_html(book.get_item_with_href(href))
|
# 4. Slice content between sorted entries
|
||||||
self.content_texts[href] = text
|
self.content_texts = {}
|
||||||
self.content_lengths[href] = calculate_text_length(text)
|
self.content_lengths = {}
|
||||||
|
num_entries = len(all_toc_entries)
|
||||||
|
|
||||||
|
for i in range(num_entries):
|
||||||
|
current_entry = all_toc_entries[i]
|
||||||
|
current_href = current_entry["href"]
|
||||||
|
current_doc = current_entry["doc_href"]
|
||||||
|
current_pos = current_entry["position"]
|
||||||
|
current_doc_html = self.doc_content.get(current_doc, "")
|
||||||
|
|
||||||
|
start_slice_pos = current_pos
|
||||||
|
slice_html = ""
|
||||||
|
|
||||||
|
# Find the start of the next TOC entry
|
||||||
|
next_entry = all_toc_entries[i + 1] if (i + 1) < num_entries else None
|
||||||
|
|
||||||
|
if next_entry:
|
||||||
|
next_doc = next_entry["doc_href"]
|
||||||
|
next_pos = next_entry["position"]
|
||||||
|
|
||||||
|
if current_doc == next_doc:
|
||||||
|
# Next entry is in the same document
|
||||||
|
slice_html = current_doc_html[start_slice_pos:next_pos]
|
||||||
|
else:
|
||||||
|
# Next entry is in a different document
|
||||||
|
# Take content from current position to end of current document
|
||||||
|
slice_html = current_doc_html[start_slice_pos:]
|
||||||
|
# Include content from intermediate documents in the spine
|
||||||
|
current_doc_index = current_entry["doc_order"]
|
||||||
|
next_doc_index = next_entry["doc_order"]
|
||||||
|
for doc_idx in range(current_doc_index + 1, next_doc_index):
|
||||||
|
intermediate_doc_href = spine_docs[doc_idx]
|
||||||
|
slice_html += self.doc_content.get(intermediate_doc_href, "")
|
||||||
|
# Add content from the beginning of the next document up to the next entry's position
|
||||||
|
next_doc_html = self.doc_content.get(next_doc, "")
|
||||||
|
slice_html += next_doc_html[:next_pos]
|
||||||
|
else:
|
||||||
|
# This is the last TOC entry
|
||||||
|
# Take content from current position to end of current document
|
||||||
|
slice_html = current_doc_html[start_slice_pos:]
|
||||||
|
# Include content from all remaining documents in the spine
|
||||||
|
current_doc_index = current_entry["doc_order"]
|
||||||
|
for doc_idx in range(current_doc_index + 1, len(spine_docs)):
|
||||||
|
intermediate_doc_href = spine_docs[doc_idx]
|
||||||
|
slice_html += self.doc_content.get(intermediate_doc_href, "")
|
||||||
|
|
||||||
|
# 5. Extract text and store
|
||||||
|
slice_soup = BeautifulSoup(slice_html, 'html.parser')
|
||||||
|
|
||||||
|
# Remove sup and sub tags from the HTML before extracting text
|
||||||
|
for tag in slice_soup.find_all(['sup', 'sub']):
|
||||||
|
tag.decompose()
|
||||||
|
|
||||||
|
text = clean_text(slice_soup.get_text()).strip()
|
||||||
|
self.content_texts[current_href] = text # Store using the original TOC href
|
||||||
|
self.content_lengths[current_href] = len(text)
|
||||||
|
|
||||||
|
# 6. Handle content BEFORE the first TOC entry
|
||||||
|
if all_toc_entries:
|
||||||
|
first_entry = all_toc_entries[0]
|
||||||
|
first_doc_href = first_entry["doc_href"]
|
||||||
|
first_pos = first_entry["position"]
|
||||||
|
first_doc_order = first_entry["doc_order"]
|
||||||
|
prefix_html = ""
|
||||||
|
# Include content from documents before the first entry's document
|
||||||
|
for doc_idx in range(first_doc_order):
|
||||||
|
intermediate_doc_href = spine_docs[doc_idx]
|
||||||
|
prefix_html += self.doc_content.get(intermediate_doc_href, "")
|
||||||
|
# Include content from the start of the first entry's document up to its position
|
||||||
|
first_doc_html = self.doc_content.get(first_doc_href, "")
|
||||||
|
prefix_html += first_doc_html[:first_pos]
|
||||||
|
|
||||||
|
if prefix_html.strip():
|
||||||
|
prefix_soup = BeautifulSoup(prefix_html, 'html.parser')
|
||||||
|
# Remove sup and sub tags
|
||||||
|
for tag in prefix_soup.find_all(['sup', 'sub']):
|
||||||
|
tag.decompose()
|
||||||
|
prefix_text = clean_text(prefix_soup.get_text()).strip()
|
||||||
|
|
||||||
|
if prefix_text:
|
||||||
|
# Create a new chapter for content before the first TOC entry
|
||||||
|
# Use a synthetic href to avoid collision with real TOC entries
|
||||||
|
prefix_chapter_href = "prefix_content_chapter"
|
||||||
|
self.content_texts[prefix_chapter_href] = prefix_text
|
||||||
|
self.content_lengths[prefix_chapter_href] = len(prefix_text)
|
||||||
|
|
||||||
|
# Add a new entry to the TOC for the prefix content
|
||||||
|
prefix_link = epub.Link(prefix_chapter_href, "Introduction", prefix_chapter_href)
|
||||||
|
# Insert at beginning of TOC
|
||||||
|
if isinstance(self.book.toc, list):
|
||||||
|
self.book.toc.insert(0, (prefix_link,))
|
||||||
|
else:
|
||||||
|
self.book.toc = [(prefix_link,)] + (self.book.toc or [])
|
||||||
|
|
||||||
def _preprocess_pdf_content(self):
|
def _preprocess_pdf_content(self):
|
||||||
"""Pre-process all page contents from PDF document"""
|
"""Pre-process all page contents from PDF document"""
|
||||||
@@ -228,16 +389,21 @@ class HandlerDialog(QDialog):
|
|||||||
|
|
||||||
def _build_epub_tree(self):
|
def _build_epub_tree(self):
|
||||||
"""Build the tree for EPUB files from TOC"""
|
"""Build the tree for EPUB files from TOC"""
|
||||||
checkable_hrefs = set()
|
self.treeWidget.clear()
|
||||||
|
info_item = QTreeWidgetItem(self.treeWidget, ["Information"])
|
||||||
|
info_item.setData(0, Qt.UserRole, "info:bookinfo")
|
||||||
|
info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable)
|
||||||
|
font = info_item.font(0)
|
||||||
|
font.setBold(True)
|
||||||
|
info_item.setFont(0, font)
|
||||||
|
|
||||||
|
# Regular tree building
|
||||||
def build_tree(toc_entries, parent_item):
|
def build_tree(toc_entries, parent_item):
|
||||||
for entry in toc_entries:
|
for entry in toc_entries:
|
||||||
href, title, children = None, "Unknown", []
|
href, title, children = None, "Unknown", []
|
||||||
|
|
||||||
if isinstance(entry, ebooklib.epub.Link):
|
if isinstance(entry, ebooklib.epub.Link):
|
||||||
href, title = entry.href, entry.title or entry.href
|
href, title = entry.href, entry.title or entry.href
|
||||||
elif isinstance(entry, tuple) and len(entry) >= 1:
|
elif isinstance(entry, tuple) and len(entry) >= 1:
|
||||||
# Handle nested sections
|
|
||||||
section_or_link = entry[0]
|
section_or_link = entry[0]
|
||||||
if isinstance(section_or_link, ebooklib.epub.Section):
|
if isinstance(section_or_link, ebooklib.epub.Section):
|
||||||
title = section_or_link.title
|
title = section_or_link.title
|
||||||
@@ -247,44 +413,28 @@ class HandlerDialog(QDialog):
|
|||||||
section_or_link.href,
|
section_or_link.href,
|
||||||
section_or_link.title or section_or_link.href,
|
section_or_link.title or section_or_link.href,
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(entry) > 1 and isinstance(entry[1], list):
|
if len(entry) > 1 and isinstance(entry[1], list):
|
||||||
children = entry[1]
|
children = entry[1]
|
||||||
else:
|
else:
|
||||||
continue # Skip unknown entry types
|
continue
|
||||||
|
|
||||||
# Use the href without fragment for content lookup
|
|
||||||
lookup_href = href.split("#")[0] if href else None
|
|
||||||
|
|
||||||
|
# Create tree item
|
||||||
item = QTreeWidgetItem(parent_item, [title])
|
item = QTreeWidgetItem(parent_item, [title])
|
||||||
item.setData(0, Qt.UserRole, href) # Store original href
|
item.setData(0, Qt.UserRole, href)
|
||||||
|
|
||||||
# Make item checkable if it has content or children
|
# Make item checkable if it has content
|
||||||
has_content = (
|
has_content = href and href in self.content_texts and self.content_texts[href].strip()
|
||||||
lookup_href
|
if has_content or children:
|
||||||
and lookup_href in self.content_texts
|
|
||||||
and self.content_texts[lookup_href]
|
|
||||||
)
|
|
||||||
is_potentially_checkable = has_content or children
|
|
||||||
|
|
||||||
# Only make the first occurrence of a given lookup_href checkable
|
|
||||||
if is_potentially_checkable and (
|
|
||||||
not lookup_href or lookup_href not in checkable_hrefs
|
|
||||||
):
|
|
||||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||||
if lookup_href:
|
|
||||||
checkable_hrefs.add(lookup_href)
|
|
||||||
# Set initial state based on provided checks
|
|
||||||
is_checked = href and href in self.checked_chapters
|
is_checked = href and href in self.checked_chapters
|
||||||
item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked)
|
item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked)
|
||||||
else:
|
else:
|
||||||
# Not checkable if duplicate content or no content/children
|
|
||||||
item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable)
|
item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable)
|
||||||
|
|
||||||
|
# Process children
|
||||||
if children:
|
if children:
|
||||||
build_tree(children, item)
|
build_tree(children, item)
|
||||||
|
|
||||||
# Build the tree from the TOC
|
|
||||||
build_tree(self.book.toc, self.treeWidget)
|
build_tree(self.book.toc, self.treeWidget)
|
||||||
|
|
||||||
def _build_pdf_tree(self):
|
def _build_pdf_tree(self):
|
||||||
@@ -501,6 +651,20 @@ class HandlerDialog(QDialog):
|
|||||||
self.previewEdit.setMinimumWidth(300)
|
self.previewEdit.setMinimumWidth(300)
|
||||||
self.previewEdit.setStyleSheet("QTextEdit { border: none; }")
|
self.previewEdit.setStyleSheet("QTextEdit { border: none; }")
|
||||||
|
|
||||||
|
# Create informative text label below preview
|
||||||
|
self.previewInfoLabel = QLabel("*Note: You can modify the content later using the \"Edit\" button in the input box or by accessing the temporary files directory through settings.", self)
|
||||||
|
self.previewInfoLabel.setWordWrap(True)
|
||||||
|
self.previewInfoLabel.setStyleSheet("QLabel { color: #666; font-style: italic; }")
|
||||||
|
|
||||||
|
# Right panel layout (preview and info label)
|
||||||
|
previewLayout = QVBoxLayout()
|
||||||
|
previewLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
previewLayout.addWidget(self.previewEdit, 1)
|
||||||
|
previewLayout.addWidget(self.previewInfoLabel, 0)
|
||||||
|
|
||||||
|
rightWidget = QWidget()
|
||||||
|
rightWidget.setLayout(previewLayout)
|
||||||
|
|
||||||
# Dialog buttons
|
# Dialog buttons
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
|
||||||
buttons.accepted.connect(self.accept)
|
buttons.accepted.connect(self.accept)
|
||||||
@@ -589,7 +753,7 @@ class HandlerDialog(QDialog):
|
|||||||
# Create splitter for left panel and preview
|
# Create splitter for left panel and preview
|
||||||
self.splitter = QSplitter(Qt.Horizontal)
|
self.splitter = QSplitter(Qt.Horizontal)
|
||||||
self.splitter.addWidget(leftWidget)
|
self.splitter.addWidget(leftWidget)
|
||||||
self.splitter.addWidget(self.previewEdit)
|
self.splitter.addWidget(rightWidget) # Now using rightWidget that includes preview and label
|
||||||
self.splitter.setSizes([280, 420])
|
self.splitter.setSizes([280, 420])
|
||||||
|
|
||||||
# Set main layout
|
# Set main layout
|
||||||
@@ -858,20 +1022,19 @@ class HandlerDialog(QDialog):
|
|||||||
# Get content based on file type
|
# Get content based on file type
|
||||||
text = None
|
text = None
|
||||||
if self.file_type == "epub":
|
if self.file_type == "epub":
|
||||||
lookup_href = identifier.split("#")[0] if identifier else None
|
# For EPUB, always use the exact href from the TOC
|
||||||
text = self.content_texts.get(lookup_href, None)
|
text = self.content_texts.get(identifier)
|
||||||
else: # PDF
|
else: # PDF
|
||||||
text = self.content_texts.get(identifier, None)
|
text = self.content_texts.get(identifier)
|
||||||
|
|
||||||
# Display content or placeholder text
|
# Display content or placeholder text - never remove titles
|
||||||
if text is None:
|
if text is None:
|
||||||
self.previewEdit.setPlainText(
|
title = current.text(0)
|
||||||
"(Section title - select a sub-item for content)"
|
# Add title to preview even if no content
|
||||||
if current.childCount() > 0
|
self.previewEdit.setPlainText(f"{title}\n\n(No content available for this item)")
|
||||||
else "(No content associated with this entry)"
|
|
||||||
)
|
|
||||||
elif not text.strip():
|
elif not text.strip():
|
||||||
self.previewEdit.setPlainText("(Item is empty)")
|
title = current.text(0)
|
||||||
|
self.previewEdit.setPlainText(f"{title}\n\n(This item is empty)")
|
||||||
else:
|
else:
|
||||||
self.previewEdit.setPlainText(text)
|
self.previewEdit.setPlainText(text)
|
||||||
|
|
||||||
@@ -1011,35 +1174,29 @@ class HandlerDialog(QDialog):
|
|||||||
def _get_epub_selected_text(self):
|
def _get_epub_selected_text(self):
|
||||||
"""Get selected text from EPUB content"""
|
"""Get selected text from EPUB content"""
|
||||||
all_checked_hrefs = set()
|
all_checked_hrefs = set()
|
||||||
included_text_hrefs = set()
|
|
||||||
chapter_titles = []
|
chapter_titles = []
|
||||||
|
|
||||||
# Collect all checked hrefs
|
# Collect all checked hrefs in tree order to preserve chapter sequence
|
||||||
|
ordered_checked_items = []
|
||||||
iterator = QTreeWidgetItemIterator(self.treeWidget)
|
iterator = QTreeWidgetItemIterator(self.treeWidget)
|
||||||
while iterator.value():
|
while iterator.value():
|
||||||
item = iterator.value()
|
item = iterator.value()
|
||||||
if item.checkState(0) == Qt.Checked:
|
if item.checkState(0) == Qt.Checked:
|
||||||
href = item.data(0, Qt.UserRole)
|
href = item.data(0, Qt.UserRole)
|
||||||
if href:
|
if href and href != "info:bookinfo":
|
||||||
all_checked_hrefs.add(href)
|
all_checked_hrefs.add(href)
|
||||||
|
ordered_checked_items.append((item, href))
|
||||||
iterator += 1
|
iterator += 1
|
||||||
|
|
||||||
# Include content for all checked items
|
# Process checked items in order
|
||||||
iterator = QTreeWidgetItemIterator(self.treeWidget)
|
for item, href in ordered_checked_items:
|
||||||
while iterator.value():
|
# Always use the exact href (including fragment) from the TOC
|
||||||
item = iterator.value()
|
text = self.content_texts.get(href)
|
||||||
if item.checkState(0) == Qt.Checked:
|
if text and text.strip():
|
||||||
href = item.data(0, Qt.UserRole)
|
|
||||||
if href:
|
|
||||||
lookup_href = href.split("#")[0]
|
|
||||||
text = self.content_texts.get(lookup_href, "")
|
|
||||||
if text and lookup_href not in included_text_hrefs:
|
|
||||||
title = item.text(0)
|
title = item.text(0)
|
||||||
title = re.sub(r"^\s*-\s*", "", title).strip()
|
title = re.sub(r"^\s*-\s*", "", title).strip()
|
||||||
marker = f"<<CHAPTER_MARKER:{title}>>"
|
marker = f"<<CHAPTER_MARKER:{title}>>"
|
||||||
chapter_titles.append((title, marker + "\n" + text))
|
chapter_titles.append((title, marker + "\n" + text))
|
||||||
included_text_hrefs.add(lookup_href)
|
|
||||||
iterator += 1
|
|
||||||
|
|
||||||
return "\n\n".join([t[1] for t in chapter_titles]), all_checked_hrefs
|
return "\n\n".join([t[1] for t in chapter_titles]), all_checked_hrefs
|
||||||
|
|
||||||
@@ -1080,7 +1237,7 @@ class HandlerDialog(QDialog):
|
|||||||
return "\n\n".join(all_content), all_checked_identifiers
|
return "\n\n".join(all_content), all_checked_identifiers
|
||||||
|
|
||||||
# For PDFs with bookmarks, process content with parent-child relationships
|
# For PDFs with bookmarks, process content with parent-child relationships
|
||||||
# New logic: if only child pages are selected (not parent), use parent's name as chapter marker at first selected child
|
# If only child pages are selected (not parent), use parent's name as chapter marker at first selected child
|
||||||
iterator = QTreeWidgetItemIterator(self.treeWidget)
|
iterator = QTreeWidgetItemIterator(self.treeWidget)
|
||||||
while iterator.value():
|
while iterator.value():
|
||||||
item = iterator.value()
|
item = iterator.value()
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
from utils import get_version, get_user_config_path
|
from utils import get_version
|
||||||
|
|
||||||
# Program Information
|
# Program Information
|
||||||
PROGRAM_NAME = "abogen"
|
PROGRAM_NAME = "abogen"
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ class ConversionThread(QThread):
|
|||||||
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
|
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
|
||||||
self.log_updated.emit(f"- Output format: {self.output_format}")
|
self.log_updated.emit(f"- Output format: {self.output_format}")
|
||||||
self.log_updated.emit(f"- Save option: {self.save_option}")
|
self.log_updated.emit(f"- Save option: {self.save_option}")
|
||||||
|
if self.replace_single_newlines:
|
||||||
|
self.log_updated.emit(
|
||||||
|
f"- Replace single newlines: Yes"
|
||||||
|
)
|
||||||
|
|
||||||
# Display save_chapters_separately flag if it's set
|
# Display save_chapters_separately flag if it's set
|
||||||
if hasattr(self, "save_chapters_separately"):
|
if hasattr(self, "save_chapters_separately"):
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ class abogen(QWidget):
|
|||||||
self.use_gpu = self.config.get(
|
self.use_gpu = self.config.get(
|
||||||
"use_gpu", True
|
"use_gpu", True
|
||||||
) # Load GPU setting with default True
|
) # Load GPU setting with default True
|
||||||
|
self.replace_single_newlines = self.config.get("replace_single_newlines", False)
|
||||||
self._pending_close_event = None
|
self._pending_close_event = None
|
||||||
self.gpu_ok = False # Initialize GPU availability status
|
self.gpu_ok = False # Initialize GPU availability status
|
||||||
|
|
||||||
@@ -1081,6 +1082,8 @@ class abogen(QWidget):
|
|||||||
self.conversion_thread.file_size_str = file_size_str
|
self.conversion_thread.file_size_str = file_size_str
|
||||||
# Pass max_subtitle_words from config
|
# Pass max_subtitle_words from config
|
||||||
self.conversion_thread.max_subtitle_words = self.max_subtitle_words
|
self.conversion_thread.max_subtitle_words = self.max_subtitle_words
|
||||||
|
# Pass replace_single_newlines setting
|
||||||
|
self.conversion_thread.replace_single_newlines = self.replace_single_newlines
|
||||||
# Pass chapter count for EPUB or PDF files
|
# Pass chapter count for EPUB or PDF files
|
||||||
if self.selected_file_type in ["epub", "pdf"] and hasattr(
|
if self.selected_file_type in ["epub", "pdf"] and hasattr(
|
||||||
self, "selected_chapters"
|
self, "selected_chapters"
|
||||||
@@ -1521,6 +1524,13 @@ class abogen(QWidget):
|
|||||||
"""Show a dropdown menu for settings options."""
|
"""Show a dropdown menu for settings options."""
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
|
|
||||||
|
# Add replace single newlines option
|
||||||
|
newline_action = QAction("Replace single newlines with spaces", self)
|
||||||
|
newline_action.setCheckable(True)
|
||||||
|
newline_action.setChecked(self.replace_single_newlines)
|
||||||
|
newline_action.triggered.connect(self.toggle_replace_single_newlines)
|
||||||
|
menu.addAction(newline_action)
|
||||||
|
|
||||||
# Add max words per subtitle option
|
# Add max words per subtitle option
|
||||||
max_words_action = QAction("Configure max words per subtitle", self)
|
max_words_action = QAction("Configure max words per subtitle", self)
|
||||||
max_words_action.triggered.connect(self.set_max_subtitle_words)
|
max_words_action.triggered.connect(self.set_max_subtitle_words)
|
||||||
@@ -1567,6 +1577,11 @@ class abogen(QWidget):
|
|||||||
|
|
||||||
menu.exec_(self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height())))
|
menu.exec_(self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height())))
|
||||||
|
|
||||||
|
def toggle_replace_single_newlines(self, checked):
|
||||||
|
self.replace_single_newlines = checked
|
||||||
|
self.config["replace_single_newlines"] = checked
|
||||||
|
save_config(self.config)
|
||||||
|
|
||||||
def reveal_config_in_explorer(self):
|
def reveal_config_in_explorer(self):
|
||||||
"""Open the configuration file location in file explorer."""
|
"""Open the configuration file location in file explorer."""
|
||||||
from utils import get_user_config_path
|
from utils import get_user_config_path
|
||||||
|
|||||||
+10
-7
@@ -69,15 +69,18 @@ def get_user_config_path():
|
|||||||
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
||||||
|
|
||||||
|
|
||||||
def clean_text(text):
|
def clean_text(text, *args, **kwargs):
|
||||||
# Trim spaces and tabs at the start and end of each line, preserving blank lines
|
# Load replace_single_newlines from config
|
||||||
text = "\n".join(line.strip() for line in text.splitlines())
|
cfg = load_config()
|
||||||
|
replace_single_newlines = cfg.get("replace_single_newlines", False)
|
||||||
|
# Collapse all whitespace (excluding newlines) into single spaces per line and trim edges
|
||||||
|
lines = [re.sub(r"[^\S\n]+", " ", line).strip() for line in text.splitlines()]
|
||||||
|
text = "\n".join(lines)
|
||||||
# Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace
|
# Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace
|
||||||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||||
# Replace single newlines with spaces, but preserve double newlines
|
# Optionally replace single newlines with spaces, but preserve double newlines
|
||||||
# text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
|
if replace_single_newlines:
|
||||||
# Collapse multiple spaces and tabs into a single space
|
text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
|
||||||
text = re.sub(r"[ \t]+", " ", text)
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ Run this FFmpeg command to create the tiny video:
|
|||||||
ffmpeg -loop 1 -framerate 24 -i bg.jpg -i audio.wav -vf "ass=your_subtitle_demo.ass" -c:v libvpx-vp9 -b:v 0 -crf 30 -c:a libopus -shortest demo.webm
|
ffmpeg -loop 1 -framerate 24 -i bg.jpg -i audio.wav -vf "ass=your_subtitle_demo.ass" -c:v libvpx-vp9 -b:v 0 -crf 30 -c:a libopus -shortest demo.webm
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it! The magic happens because:
|
|
||||||
- We use a single static background image instead of many frames
|
|
||||||
- The subtitles are stored as text (vector data), not as pixels
|
|
||||||
- VP9 video codec with Opus audio provides excellent compression
|
|
||||||
|
|
||||||
## For Higher Quality (But Larger) Video (.mp4)
|
## For Higher Quality (But Larger) Video (.mp4)
|
||||||
|
|
||||||
If you need better quality for distribution, use this command instead:
|
If you need better quality for distribution, use this command instead:
|
||||||
|
|||||||
Reference in New Issue
Block a user