Further refactoring of book_handler logic into PDFParser

Also deletes the unnencessary abogen/book_handler and just use pyqt/book_hanlder
This commit is contained in:
Mohan Krishnan
2026-01-09 08:44:33 +08:00
parent 5ae153f841
commit eb7d135bc3
6 changed files with 261 additions and 1784 deletions
File diff suppressed because it is too large Load Diff
+133 -16
View File
@@ -110,41 +110,158 @@ class PdfParser(BaseBookParser):
logging.error(f"Error loading PDF {self.book_path}: {e}")
raise
def _extract_book_metadata(self):
# PDF metadata extraction can be added here if needed
# For now, base class metadata is empty dict
pass
def process_content(self, replace_single_newlines=True):
if not self.pdf_doc:
self.load()
# 1. Extract text from all pages first
for page_num in range(len(self.pdf_doc)):
text = clean_text(self.pdf_doc[page_num].get_text())
# Clean up common PDF artifacts:
# - Remove bracketed numbers often used for citations [1]
text = _BRACKETED_NUMBERS_PATTERN.sub("", text)
# - Remove standalone page numbers often found in headers/footers
text = _STANDALONE_PAGE_NUMBERS_PATTERN.sub("", text)
# - Remove page numbers at end of lines
text = _PAGE_NUMBERS_AT_END_PATTERN.sub("", text)
# - Remove page numbers with dashes - 4 -
text = _PAGE_NUMBERS_WITH_DASH_PATTERN.sub("", text)
page_id = f"page_{page_num + 1}"
self.content_texts[page_id] = text
self.content_lengths[page_id] = calculate_text_length(text)
# 2. Build Navigation Structure
toc = self.pdf_doc.get_toc()
if not toc:
# Fallback: Flat list of pages if no TOC
self.processed_nav_structure = []
pages_node = {
"title": "Pages",
"src": None,
"children": [],
"has_content": False
}
# Add all pages as children
for page_num in range(len(self.pdf_doc)):
page_id = f"page_{page_num + 1}"
title = self._get_page_title(page_num, self.content_texts.get(page_id, ""))
pages_node["children"].append({
"title": title,
"src": page_id,
"children": [],
"has_content": True
})
self.processed_nav_structure.append(pages_node)
else:
self.processed_nav_structure = self._build_structure_from_toc(toc)
return self.content_texts, self.content_lengths
def _extract_book_metadata(self):
# PDF metadata extraction can be added here if needed
# For now, base class metadata is empty dict
pass
def _get_page_title(self, page_num, text):
title = f"Page {page_num + 1}"
if text:
first_line = text.split("\n", 1)[0].strip()
if first_line and len(first_line) < 100:
title += f" - {first_line}"
return title
def get_chapters(self):
# PDF specific implementation because it doesn't use nav structure
chapters = []
if self.pdf_doc:
for i in range(len(self.pdf_doc)):
chapters.append((f"page_{i+1}", f"Page {i+1}"))
return chapters
def _build_structure_from_toc(self, toc):
# 1. Flatten TOC to easier list (page_num, title, level)
# fitz TOC is [[lvl, title, page, dest], ...]
bookmarks = []
for entry in toc:
lvl, title, page = entry[:3]
if isinstance(page, int):
page_idx = page - 1
else:
# Handle potential complex destinations if necessary, but usually simple int
# PyMuPDF docs say int.
page_idx = -1
if page_idx >= 0:
bookmarks.append({"level": lvl, "title": title, "page": page_idx})
root_children = []
stack = [] # Stack of (level, list_to_append_to)
stack.append((0, root_children))
# Step 1: Build the Skeleton Tree from TOC
# And keep a flat list of these nodes to associate with pages.
processed_nodes = [] # List of (page_idx, node_dict)
for entry in bookmarks:
node = {
"title": entry["title"],
"src": f"page_{entry['page'] + 1}",
"children": [],
"has_content": True
}
# Find parent
level = entry["level"]
# Adjust stack
while stack and stack[-1][0] >= level:
stack.pop()
parent_list = stack[-1][1]
parent_list.append(node)
stack.append((level, node["children"]))
processed_nodes.append((entry["page"], node))
# Step 3: Add gap pages.
# Sort processed_nodes by page index to find ranges.
sorted_bookmarks = sorted(processed_nodes, key=lambda x: x[0])
# Set of pages that are "bookmarks"
bookmarked_pages = set(p for p, n in sorted_bookmarks)
current_node = None
# We need a way to look up bookmarks starting at p
bookmarks_by_page = {}
for p, node in processed_nodes:
if p not in bookmarks_by_page:
bookmarks_by_page[p] = []
bookmarks_by_page[p].append(node)
# Let's iterate.
for page_num in range(len(self.pdf_doc)):
page_id = f"page_{page_num + 1}"
# Check if this page STARTS bookmarks
if page_num in bookmarks_by_page:
starts = bookmarks_by_page[page_num]
current_node = starts[-1]
continue
# If page is NOT a bookmark, it's a "gap page".
# Add as child to current_node
title = self._get_page_title(page_num, self.content_texts.get(page_id, ""))
page_node = {
"title": title,
"src": page_id,
"children": [],
"has_content": True
}
if current_node:
current_node["children"].append(page_node)
else:
# No preceding bookmark. Add to root.
root_children.append(page_node)
return root_children
class MarkdownParser(BaseBookParser):
+7 -216
View File
@@ -394,10 +394,7 @@ class HandlerDialog(QDialog):
font.setBold(True)
info_item.setFont(0, font)
# TODO look into why we need this here and not just rely on logic in PDFParser
if self.parser.file_type == "pdf":
self._build_pdf_tree()
elif self.processed_nav_structure:
if self.processed_nav_structure:
self._build_tree_from_nav(
self.processed_nav_structure, self.treeWidget
)
@@ -471,200 +468,6 @@ class HandlerDialog(QDialog):
self._build_tree_from_nav(children, item, seen_content_hashes)
# TODO look into why we need this here and not just rely on logic in PDFParser
def _build_pdf_tree(self):
pdf_doc = getattr(self.parser, "pdf_doc", None)
if not pdf_doc:
return
outline = pdf_doc.get_toc()
self.has_pdf_bookmarks = bool(outline)
self.bookmark_items_map = {}
if not outline:
self._build_pdf_pages_tree()
return
bookmark_pages = []
page_to_bookmark = {}
next_page_boundaries = {}
added_pages = set()
def extract_page_numbers(entries):
for entry in entries:
if len(entry) >= 3:
_, title, page = entry[:3]
page_num = (
page - 1
if isinstance(page, int)
else pdf_doc.resolve_link(page)[0]
)
bookmark_pages.append((page_num, title))
if len(entry) > 3 and isinstance(entry[3], list):
extract_page_numbers(entry[3])
extract_page_numbers(outline)
bookmark_pages.sort()
for i, (page_num, title) in enumerate(bookmark_pages):
if i < len(bookmark_pages) - 1:
next_page_boundaries[page_num] = bookmark_pages[i + 1][0]
page_to_bookmark[page_num] = title
def build_outline_tree(entries, parent_item):
for entry in entries:
if len(entry) >= 3:
entry_level, title, page = entry[:3]
page_num = (
page - 1
if isinstance(page, int)
else pdf_doc.resolve_link(page)[0]
)
page_id = f"page_{page_num + 1}"
# attach chapters on same page under original
if page_num in self.bookmark_items_map:
orig = self.bookmark_items_map[page_num]
child = QTreeWidgetItem(orig, [f"{title} (Same page)"])
child.setData(0, Qt.ItemDataRole.UserRole, page_id)
child.setFlags(child.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
continue
bookmark_item = QTreeWidgetItem(parent_item, [title])
bookmark_item.setData(0, Qt.ItemDataRole.UserRole, page_id)
# only allow checking if this chapter has content
if self.content_lengths.get(page_id, 0) > 0:
bookmark_item.setFlags(
bookmark_item.flags() | Qt.ItemFlag.ItemIsUserCheckable
)
bookmark_item.setCheckState(
0,
(
Qt.CheckState.Checked
if page_id in self.checked_chapters
else Qt.CheckState.Unchecked
),
)
else:
bookmark_item.setFlags(
bookmark_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable
)
# map for uncategorized pages
self.bookmark_items_map[page_num] = bookmark_item
added_pages.add(page_num)
next_page = next_page_boundaries.get(page_num, len(pdf_doc))
for sub_page_num in range(page_num + 1, next_page):
if (
sub_page_num in page_to_bookmark
or sub_page_num in added_pages
):
continue
page_id = f"page_{sub_page_num + 1}"
page_title = f"Page {sub_page_num + 1}"
page_text = self.content_texts.get(page_id, "").strip()
if page_text:
first_line = page_text.split("\n", 1)[0].strip()
if first_line and len(first_line) < 100:
page_title += f" - {first_line}"
page_item = QTreeWidgetItem(bookmark_item, [page_title])
page_item.setData(0, Qt.ItemDataRole.UserRole, page_id)
# only allow checking if this sub-page has content
if self.content_lengths.get(page_id, 0) > 0:
page_item.setFlags(
page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable
)
page_item.setCheckState(
0,
(
Qt.CheckState.Checked
if page_id in self.checked_chapters
else Qt.CheckState.Unchecked
),
)
else:
page_item.setFlags(
page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable
)
added_pages.add(sub_page_num)
build_outline_tree(outline, self.treeWidget)
covered_pages = set(added_pages)
# attach any pages without direct bookmarks under nearest preceding chapter
uncategorized_pages = [
i for i in range(len(pdf_doc)) if i not in covered_pages
]
for page_num in uncategorized_pages:
# find nearest previous bookmark
prev_nums = [n for n in sorted(self.bookmark_items_map) if n < page_num]
parent_item = (
self.bookmark_items_map[prev_nums[-1]] if prev_nums else self.treeWidget
)
page_id = f"page_{page_num + 1}"
title = f"Page {page_num + 1}"
text = self.content_texts.get(page_id, "").strip()
if text:
first = text.split("\n", 1)[0].strip()
if first and len(first) < 100:
title += f" - {first}"
page_item = QTreeWidgetItem(parent_item, [title])
page_item.setData(0, Qt.ItemDataRole.UserRole, page_id)
# only allow checking if uncategorized page has content
if self.content_lengths.get(page_id, 0) > 0:
page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
page_item.setCheckState(
0,
(
Qt.CheckState.Checked
if page_id in self.checked_chapters
else Qt.CheckState.Unchecked
),
)
else:
page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
def _build_pdf_pages_tree(self):
pdf_doc = getattr(self.parser, "pdf_doc", None)
if not pdf_doc:
return
pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"])
pages_item.setFlags(pages_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
font = pages_item.font(0)
font.setBold(True)
pages_item.setFont(0, font)
for page_num in range(len(pdf_doc)):
page_id = f"page_{page_num + 1}"
page_title = f"Page {page_num + 1}"
page_text = self.content_texts.get(page_id, "").strip()
if page_text:
first_line = page_text.split("\n", 1)[0].strip()
if first_line and len(first_line) < 100:
page_title += f" - {first_line}"
page_item = QTreeWidgetItem(pages_item, [page_title])
page_item.setData(0, Qt.ItemDataRole.UserRole, page_id)
# only allow checking if standalone page has content
if self.content_lengths.get(page_id, 0) > 0:
page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
page_item.setCheckState(
0,
(
Qt.CheckState.Checked
if page_id in self.checked_chapters
else Qt.CheckState.Unchecked
),
)
else:
page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
def _are_provided_checks_relevant(self):
if not self.checked_chapters:
return False
@@ -989,15 +792,6 @@ class HandlerDialog(QDialog):
iterator += 1
def _run_pdf_auto_check(self):
if hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks:
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
if item.flags() & Qt.ItemFlag.ItemIsUserCheckable:
item.setCheckState(0, Qt.CheckState.Checked)
iterator += 1
return
iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value():
item = iterator.value()
@@ -1006,16 +800,13 @@ class HandlerDialog(QDialog):
continue
identifier = item.data(0, Qt.ItemDataRole.UserRole)
if not identifier:
iterator += 1
continue
if (
not identifier.startswith("page_")
or self.content_lengths.get(identifier, 0) > 0
):
item.setCheckState(0, Qt.CheckState.Checked)
iterator += 1
continue
# Logic: Check item if it has content (already handled by ItemIsUserCheckable flag really)
# But duplicate logic from previous implementation:
item.setCheckState(0, Qt.CheckState.Checked)
iterator += 1