diff --git a/readme.md b/readme.md
index 0163e26..d31b052 100644
--- a/readme.md
+++ b/readme.md
@@ -35,7 +35,8 @@ Windows/macOS/Ubuntu:
+ 目录文本:将目录文本粘贴到“目录文本”框中。[如何获取目录文本](#获取目录文本)。对于已有目录页的文字版或扫描版 PDF,也可以点击“自动读取目录”从 PDF 前部目录页中识别并填充目录文本。
+ 编辑写入目录(可选项):根据目录文本自动生成的实际写入目录,可双击任一目录或页数进行编辑。同时支持拖动改变顺序/目录上下级关系。
+ 编辑页差(可选项):当目录中的标注页码与 PDF 实际页数不一致时,可在“页差”中填写差值,程序会在预览中换算出实际页数。也可以点击“自动填充页差”根据目录标题在 PDF 中的位置自动推断页差;文字版 PDF 可直接使用,扫描版 PDF 需要安装 OCR 可选依赖。
-+ 写入:点击右下角的“写入”按钮,稍等片刻,待状态栏提示"******* Finished!"表示写入成功,此时可在pdf目录下找到包含书签的 *原文件名\_new.pdf* 文件。
++ 阅读器页码(可选项):默认保留原 PDF 已有的页码标签。若正文第 1 页对应 PDF 第 31 页,可选择“前置页罗马数字,正文从 1 开始”,程序会给前 30 页设置 `i…xxx`,第 31 页起设置 `1、2、3…`。起始页默认依据“页差 + 1”填写,也可关闭“根据页差”手动指定;手动指定不会改变书签跳转页。此选项会替换原 PDF 的已有页码标签。
++ 写入:点击右下角的“写入”按钮,待提示成功后,可在 PDF 目录下找到同时包含书签和所选页码标签的 *原文件名\_new.pdf* 文件。已有同名输出文件时会询问是否替换。
### 获取目录文本
@@ -72,7 +73,7 @@ Windows/macOS/Ubuntu:
运行源码所需环境:
-+ Python2/3 均可,推荐Python3
++ Python 3.7 或更新版本
+ PyQt5
+ PyPDF
+ six
@@ -120,26 +121,13 @@ https://www.python.org/downloads/
通过cli运行接口支持最多6级目录, 目录文本通过文件输入更加容易编辑.
```
-python run_cli.py --help myrepo/pdfdir
-usage: run_cli.py [-h] [--offset OFFSET] [--l0 L0] [--l1 L1] [--l2 L2] [--l3 L3] [--l4 L4] [--l5 L5] pdfPath tocPath
-
-Add content to PDF.
-
-positional arguments:
- pdfPath path of PDF
- tocPath path of contents file
-
-options:
- -h, --help show this help message and exit
- --offset OFFSET Page offset of contents
- --l0 L0 Regular expression of level 0 of content
- --l1 L1 Regular expression of level 1 of content
- --l2 L2 Regular expression of level 2 of content
- --l3 L3 Regular expression of level 3 of content
- --l4 L4 Regular expression of level 4 of content
- --l5 L5 Regular expression of level 5 of content
+python run_cli.py book.pdf toc.txt --offset 30 --page-labels roman-body
+# 正文起始页与页差不同,也可明确指定:
+python run_cli.py book.pdf toc.txt --offset 30 --page-labels roman-body --body-start-page 31
```
+默认 `--page-labels preserve` 会保留原文件页码标签;`roman-body` 会写入罗马数字前置页和从 1 开始的正文页码。运行 `python run_cli.py --help` 可查看其余目录层级参数。CLI 若已有同名输出文件会替换它。
+
### 打包源码
如果你想在本机打包此程序:
diff --git a/run_cli.py b/run_cli.py
index bcc76f1..0f3575d 100644
--- a/run_cli.py
+++ b/run_cli.py
@@ -1,12 +1,21 @@
import argparse
from src.pdfdirectory import add_directory
+from src.pdf.page_labels import PageLabelPlan
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Add content to PDF.")
parser.add_argument("pdfPath", type=str, help="path of PDF")
parser.add_argument("tocPath", type=str, help="path of contents file")
parser.add_argument("--offset", type=int, default=0, help="Page offset of contents")
+ parser.add_argument(
+ "--page-labels", choices=("preserve", "roman-body"), default="preserve",
+ help="Preserve source page labels, or number front matter i, ii... and body 1, 2...",
+ )
+ parser.add_argument(
+ "--body-start-page", type=int, default=None,
+ help="One-based physical PDF page where body page 1 starts; defaults to offset + 1",
+ )
parser.add_argument(
"--l0",
type=str,
@@ -48,11 +57,24 @@
pdfPath = args.pdfPath
tocPath = args.tocPath
offset = args.offset
+ if args.page_labels == "preserve" and args.body_start_page is not None:
+ parser.error("--body-start-page requires --page-labels roman-body")
+ if args.page_labels == "roman-body":
+ body_start = args.body_start_page if args.body_start_page is not None else offset + 1
+ if body_start < 1:
+ parser.error("body start page must be at least 1; pass --body-start-page")
+ label_plan = PageLabelPlan("roman-body", body_start)
+ else:
+ label_plan = PageLabelPlan()
# -- load toc
f = open(tocPath)
toc = f.read()
f.close()
- add_directory(
- toc, offset, pdfPath, args.l0, args.l1, args.l2, args.l3, args.l4, args.l5
- )
+ try:
+ add_directory(
+ toc, offset, pdfPath, args.l0, args.l1, args.l2, args.l3, args.l4, args.l5,
+ page_label_plan=label_plan,
+ )
+ except ValueError as exc:
+ parser.error(str(exc))
diff --git a/src/gui/en.qm b/src/gui/en.qm
index 0d9a14f..eb0f542 100644
Binary files a/src/gui/en.qm and b/src/gui/en.qm differ
diff --git a/src/gui/en.ts b/src/gui/en.ts
index a64ca41..f1ed0fd 100644
--- a/src/gui/en.ts
+++ b/src/gui/en.ts
@@ -151,4 +151,31 @@
Write directory
+
+ Main
+ 阅读器页码Reader page numbers
+ 保留原文件页码Preserve source labels
+ 前置页罗马,正文从 1 开始Roman front, body from 1
+ 根据页差Use page offset
+ PDF 第 PDF page
+ 页 (physical)
+ 导出时保留原 PDF 的页码规则Keep the source PDF page labels
+ 页差不能推导正文起始页;取消勾选「根据页差」后手动指定Uncheck Use page offset, then set body start manually
+ 正文起始页超过 PDF 总页数Body start exceeds the PDF page count
+ PDF 第 1–{} 页:i…;第 {} 页起:1…PDF pages 1–{}: i…; page {} onward: 1…
+ PDF 第 1 页起:1…PDF page 1 onward: 1…
+ ;将替换原有页码规则; replaces source labels
+ 无法读取 PDF 页数Could not read PDF page count
+ 未导出的目录修改Unsaved directory edits
+ 打开其他文件前,如何处理当前修改?What should happen to the current edits?
+ 导出当前 PDFExport current PDF
+ 放弃修改Discard edits
+ 取消Cancel
+ 替换导出的 PDFReplace exported PDF
+ 替换已有文件?
{}Replace the existing file?
{}
+ 替换Replace
+ 正在写入 PDF…Writing PDF...
+ 已导出:Exported:
+ 导出失败:Export failed:
+
diff --git a/src/gui/main.py b/src/gui/main.py
index ae1f787..841a12b 100644
--- a/src/gui/main.py
+++ b/src/gui/main.py
@@ -5,6 +5,7 @@
"""
+import logging
import os
import sys
import traceback
@@ -22,10 +23,14 @@
from src.updater import is_updated
from src.pdf.bookmark import add_bookmark, check_bookmarks, get_bookmarks
from src.pdf.page_offset import OcrCancelledError, infer_page_offset
+from src.pdf.page_labels import PageLabelPlan
from src.pdf.toc import extract_toc_text
+from pypdf import PdfReader
# import qdarkstyle
+logger = logging.getLogger(__name__)
+
def dynamic_base_class(instance, cls_name, new_class, **kwargs):
instance.__class__ = type(cls_name, (new_class, instance.__class__), kwargs)
@@ -108,8 +113,12 @@ def __init__(self, app, trans):
self.app = app
self.trans = trans
self.setupUi(self)
+ self._active_pdf_path = self.pdf_path_edit.text()
+ self.setMinimumSize(760, 580)
+ self._pdf_page_count = 0
self._init_auto_offset_button()
self._init_auto_toc_button()
+ self._init_page_label_controls()
self._fix_small_fonts()
self.version = CONFIG.VERSION
self.default_folder = CONFIG.DEFAULT_FOLDER
@@ -129,6 +138,124 @@ def __init__(self, app, trans):
self._worker_thread = None
self._worker_busy = False
self._close_pending = False
+ self._loaded_draft = self._draft_snapshot()
+ self._update_page_label_summary()
+
+ def _draft_snapshot(self):
+ # Keep the editable cells as text: an incomplete page number must still
+ # count as a draft, not raise while the user switches documents.
+ tree = []
+ for item in self.dir_tree_widget.all_items:
+ depth = 0
+ parent = item.parent()
+ while parent is not None:
+ depth += 1
+ parent = parent.parent()
+ tree.append((depth, item.text(0), item.text(1), item.text(2)))
+ return (
+ self.dir_text,
+ self.offset_edit.text(),
+ tuple(tree),
+ self.page_label_mode.currentIndex(),
+ self.page_label_auto.isChecked(),
+ self.body_start_page.value(),
+ )
+
+ def _has_unsaved_draft(self):
+ return self._draft_snapshot() != self._loaded_draft
+
+ def _init_page_label_controls(self):
+ self.page_label_group = QtWidgets.QGroupBox(self.main_widget)
+ self.page_label_group.setObjectName("page_label_group")
+ layout = QtWidgets.QVBoxLayout(self.page_label_group)
+ self.page_label_mode = QtWidgets.QComboBox(self.page_label_group)
+ self.page_label_mode.setObjectName("page_label_mode")
+ layout.addWidget(self.page_label_mode)
+ self.page_label_auto = QtWidgets.QCheckBox(self.page_label_group)
+ self.page_label_auto.setObjectName("page_label_auto")
+ self.page_label_auto.setChecked(True)
+ self.body_start_page = QtWidgets.QSpinBox(self.page_label_group)
+ self.body_start_page.setObjectName("body_start_page")
+ self.body_start_page.setMinimum(1)
+ self.body_start_page.setMaximum(999999)
+ start_row = QtWidgets.QHBoxLayout()
+ start_row.addWidget(self.page_label_auto)
+ start_row.addWidget(self.body_start_page)
+ layout.addLayout(start_row)
+ self.page_label_summary = QtWidgets.QLabel(self.page_label_group)
+ self.page_label_summary.setObjectName("page_label_summary")
+ self.page_label_summary.setWordWrap(True)
+ layout.addWidget(self.page_label_summary)
+ self.verticalLayout_3.insertWidget(
+ self.verticalLayout_3.indexOf(self.sub_dir_group), self.page_label_group
+ )
+ self._translate_page_label_controls()
+
+ def _translate_page_label_controls(self):
+ self.page_label_group.setTitle(self.tr("阅读器页码"))
+ self.page_label_mode.blockSignals(True)
+ selected = self.page_label_mode.currentIndex()
+ self.page_label_mode.clear()
+ self.page_label_mode.addItems(
+ [self.tr("保留原文件页码"), self.tr("前置页罗马,正文从 1 开始")]
+ )
+ self.page_label_mode.setCurrentIndex(max(selected, 0))
+ self.page_label_mode.blockSignals(False)
+ self.page_label_auto.setText(self.tr("根据页差"))
+ self.body_start_page.setPrefix(self.tr("PDF 第 "))
+ self.body_start_page.setSuffix(self.tr(" 页"))
+ self._update_page_label_summary()
+
+ def _update_page_label_summary(self):
+ if not hasattr(self, "page_label_mode"):
+ return
+ generated = self.page_label_mode.currentIndex() == 1
+ self.page_label_auto.setVisible(generated)
+ self.body_start_page.setVisible(generated)
+ self.body_start_page.setEnabled(generated and not self.page_label_auto.isChecked())
+ if generated and self.page_label_auto.isChecked():
+ suggested = self.offset_num + 1
+ if suggested > 0:
+ self.body_start_page.blockSignals(True)
+ self.body_start_page.setValue(suggested)
+ self.body_start_page.blockSignals(False)
+ if not generated:
+ msg = self.tr("导出时保留原 PDF 的页码规则")
+ elif self.page_label_auto.isChecked() and self.offset_num < 0:
+ msg = self.tr("页差不能推导正文起始页;取消勾选「根据页差」后手动指定")
+ elif self._pdf_page_count and (
+ (self.page_label_auto.isChecked() and self.offset_num + 1 > self._pdf_page_count)
+ or self.body_start_page.value() > self._pdf_page_count
+ ):
+ msg = self.tr("正文起始页超过 PDF 总页数")
+ else:
+ start = self.body_start_page.value()
+ msg = (
+ self.tr("PDF 第 1–{} 页:i…;第 {} 页起:1…").format(start - 1, start)
+ if start > 1
+ else self.tr("PDF 第 1 页起:1…")
+ )
+ msg += self.tr(";将替换原有页码规则")
+ self.page_label_summary.setText(msg)
+
+ def _refresh_pdf_page_count(self):
+ self._pdf_page_count = 0
+ if os.path.isfile(self.pdf_path):
+ try:
+ self._pdf_page_count = len(PdfReader(self.pdf_path).pages)
+ except Exception:
+ logger.exception("Could not read PDF page count: %s", self.pdf_path)
+ self.show_status(self.tr("无法读取 PDF 页数"), 5000)
+ self._update_page_label_summary()
+
+ @property
+ def page_label_plan(self):
+ if self.page_label_mode.currentIndex() == 0:
+ return PageLabelPlan()
+ if self.page_label_auto.isChecked() and self.offset_num < 0:
+ raise ValueError("A negative offset cannot infer the body start page")
+ start = self.offset_num + 1 if self.page_label_auto.isChecked() else self.body_start_page.value()
+ return PageLabelPlan("roman-body", start)
def _fix_small_fonts(self):
"""Override hardcoded small font sizes from main_ui.py for readability.
@@ -206,6 +333,11 @@ def _set_connect(self):
self.fix_non_seq_action.changed,
):
act.connect(self.make_dir_tree)
+ self.offset_edit.textChanged.connect(self._update_page_label_summary)
+ self.page_label_mode.currentIndexChanged.connect(self._update_page_label_summary)
+ self.page_label_auto.stateChanged.connect(self._update_page_label_summary)
+ self.body_start_page.valueChanged.connect(self._update_page_label_summary)
+ self.pdf_path_edit.editingFinished.connect(self._on_pdf_path_edited)
def _set_action(self):
self.home_page_action.triggered.connect(self._open_home_page)
@@ -282,13 +414,44 @@ def alert_msg(msg, level="info", ok_action=None):
box.exec_()
def to_english(self):
- self.trans.load("./language/en")
+ if not self.trans.load("./language/en"):
+ self.trans.load(os.path.join(os.path.dirname(__file__), "..", "language", "en.qm"))
self.app.installTranslator(self.trans)
- self.retranslateUi(self)
+ self._retranslate_preserving_draft()
+ self._translate_page_label_controls()
def to_chinese(self):
self.app.removeTranslator(self.trans)
- self.retranslateUi(self)
+ self._retranslate_preserving_draft()
+ self._translate_page_label_controls()
+
+ def _retranslate_preserving_draft(self):
+ editable = [
+ self.dir_text_edit,
+ self.offset_edit,
+ self.level0_edit,
+ self.level1_edit,
+ self.level2_edit,
+ self.level3_edit,
+ self.level4_edit,
+ self.level5_edit,
+ self.unknown_level_box,
+ self.fix_non_seq_action,
+ ]
+ blockers = [QtCore.QSignalBlocker(widget) for widget in editable]
+ directory = self.dir_text
+ offset = self.offset_edit.text()
+ levels = [widget.text() for widget in editable[2:8]]
+ unknown_level = self.unknown_level_box.currentIndex()
+ try:
+ self.retranslateUi(self)
+ self.dir_text_edit.setPlainText(directory)
+ self.offset_edit.setText(offset)
+ for widget, value in zip(editable[2:8], levels):
+ widget.setText(value)
+ self.unknown_level_box.setCurrentIndex(unknown_level)
+ finally:
+ del blockers
@property
def pdf_path(self):
@@ -353,14 +516,57 @@ def open_file_dialog(self):
filename, _ = QtWidgets.QFileDialog.getOpenFileName(
self, "select PDF", directory=self.default_folder, filter="PDF (*.pdf)"
)
- self.default_folder = os.path.dirname(filename)
+ if filename:
+ self._open_pdf(filename)
+
+ def _on_pdf_path_edited(self):
+ filename = self.pdf_path_edit.text().strip()
+ if filename == self._active_pdf_path:
+ return True
+ # Restore the current document while its draft and possible export are
+ # resolved; the new path is accepted only after that decision.
+ blocker = QtCore.QSignalBlocker(self.pdf_path_edit)
+ self.pdf_path_edit.setText(self._active_pdf_path)
+ del blocker
+ return self._open_pdf(filename)
+
+ def _open_pdf(self, filename):
+ if filename == self._active_pdf_path:
+ return True
+ previous_path = self._active_pdf_path
+ if previous_path and self._has_unsaved_draft():
+ box = QMessageBox(self)
+ box.setWindowTitle(self.tr("未导出的目录修改"))
+ box.setText(self.tr("打开其他文件前,如何处理当前修改?"))
+ export = box.addButton(self.tr("导出当前 PDF"), QMessageBox.AcceptRole)
+ discard = box.addButton(self.tr("放弃修改"), QMessageBox.DestructiveRole)
+ cancel = box.addButton(self.tr("取消"), QMessageBox.RejectRole)
+ box.setDefaultButton(cancel)
+ box.exec_()
+ if box.clickedButton() == cancel:
+ return False
+ if box.clickedButton() == export and not self.write_tree_to_pdf():
+ return False
+ if box.clickedButton() != discard and box.clickedButton() != export:
+ return False
+ if filename:
+ self.default_folder = os.path.dirname(filename)
+ if previous_path:
+ self.dir_text_edit.clear()
+ self.offset_edit.setText("0")
self.pdf_path_edit.setText(filename)
+ self._active_pdf_path = filename
+ self.page_label_mode.setCurrentIndex(0)
+ self.page_label_auto.setChecked(True)
+ self._refresh_pdf_page_count()
exist_bookmarks = self.read_pdf_dir_text(filename)
if exist_bookmarks and self.read_exist_dir:
exist_bookmarks = clean_clipboard_control_chars(exist_bookmarks)
self.dir_text_edit.setText(exist_bookmarks)
self.space_level_box.setChecked(True)
+ self._loaded_draft = self._draft_snapshot()
+ return True
def tree_to_dict(self):
return self.dir_tree_widget.to_dict()
@@ -550,23 +756,54 @@ def closeEvent(self, event):
super(Main, self).closeEvent(event)
def pre_check(self, path, index_dict):
- try:
- check_bookmarks(path, index_dict, self.keep_exist_dir)
- except ValueError as e:
- self.alert_msg(str(e), level="Warning")
+ page_count = len(PdfReader(path).pages)
+ check_bookmarks(path, index_dict, page_count=page_count)
+ self.page_label_plan.validate(page_count)
def write_tree_to_pdf(self):
+ if self.pdf_path != self._active_pdf_path:
+ self._on_pdf_path_edited()
+ return False
try:
index_dict = self.tree_to_dict()
self.pre_check(self.pdf_path, index_dict)
- new_path = self.dict_to_pdf(self.pdf_path, index_dict, self.keep_exist_dir)
+ name, ext = os.path.splitext(self.pdf_path)
+ output_path = name + "_new" + ext
+ if os.path.exists(output_path):
+ box = QMessageBox(self)
+ box.setWindowTitle(self.tr("替换导出的 PDF"))
+ box.setText(self.tr("替换已有文件?\n{}").format(output_path))
+ replace = box.addButton(self.tr("替换"), QMessageBox.DestructiveRole)
+ cancel = box.addButton(self.tr("取消"), QMessageBox.RejectRole)
+ box.setDefaultButton(cancel)
+ box.exec_()
+ if box.clickedButton() != replace:
+ return False
+ self.export_button.setEnabled(False)
+ self.show_status(self.tr("正在写入 PDF…"))
+ QtWidgets.QApplication.processEvents()
+ new_path = self.dict_to_pdf(
+ self.pdf_path, index_dict, self.keep_exist_dir, self.page_label_plan
+ )
+ self.show_status(self.tr("已导出:") + new_path, 5000)
self.alert_msg("%s Finished!" % new_path)
- except PermissionError:
- self.alert_msg("Permission denied!", level="warn")
+ self._loaded_draft = self._draft_snapshot()
+ return True
+ except (PermissionError, ValueError) as exc:
+ self.show_status(self.tr("导出失败:") + str(exc), 5000)
+ self.alert_msg(str(exc), level="warn")
+ return False
+ except Exception as exc:
+ logger.exception("PDF export failed")
+ self.show_status(self.tr("导出失败:") + str(exc), 5000)
+ self.alert_msg(str(exc), level="warn")
+ return False
+ finally:
+ self.export_button.setEnabled(True)
@staticmethod
- def dict_to_pdf(pdf_path, index_dict, keep_exist_dir=False):
- return add_bookmark(pdf_path, index_dict, keep_exist_dir)
+ def dict_to_pdf(pdf_path, index_dict, keep_exist_dir=False, page_label_plan=None):
+ return add_bookmark(pdf_path, index_dict, keep_exist_dir, page_label_plan)
@staticmethod
def read_pdf_dir_text(pdf_path):
diff --git a/src/language/en.qm b/src/language/en.qm
index 0d9a14f..eb0f542 100644
Binary files a/src/language/en.qm and b/src/language/en.qm differ
diff --git a/src/pdf/bookmark.py b/src/pdf/bookmark.py
index 6bba6f9..84944da 100644
--- a/src/pdf/bookmark.py
+++ b/src/pdf/bookmark.py
@@ -56,13 +56,16 @@ def merge_bookmarks(existing_bookmarks, new_bookmarks):
return merged
-def add_bookmark(path, index_dict, keep_exist_dir=False):
+def add_bookmark(path, index_dict, keep_exist_dir=False, page_label_plan=None):
"""
Add directory bookmarks to the pdf file.
:param path: pdf file path.
:param index_dict: bookmarks dict, like {0:{'title':'A', 'pagenum':1}, 1:{'title':'B', pagenum:2, parent: 0} ......}
"""
- pdf = Pdf(path, keep_outline=keep_exist_dir)
+ pdf = Pdf(
+ path, keep_outline=keep_exist_dir, page_label_plan=page_label_plan
+ )
+ check_bookmarks(path, index_dict, page_count=len(pdf.reader.pages))
_add_bookmark(pdf, index_dict)
return pdf.save_pdf()
@@ -77,15 +80,20 @@ def get_bookmarks(path):
return []
-def check_bookmarks(path, index_dict, keep_exist_dir=False):
+def check_bookmarks(path, index_dict, page_count=None):
if not index_dict:
return
- pdf = Pdf(path, keep_outline=keep_exist_dir)
- max_page_num = len(pdf.writer.pages)
- max_set_page_num = max([v.get("real_num", 1) for v in index_dict.values()])
- if max_set_page_num > max_page_num:
- raise ValueError(
- "Max page number '{}' exceeds the pdf real page number '{}'!".format(
- max_set_page_num, max_page_num
+ if page_count is None:
+ from pypdf import PdfReader
+
+ page_count = len(PdfReader(path).pages)
+ for value in index_dict.values():
+ page = value.get("real_num", 1)
+ if (
+ not isinstance(page, int)
+ or isinstance(page, bool)
+ or not 1 <= page <= page_count
+ ):
+ raise ValueError(
+ "Bookmark page '{}' must be between 1 and {}".format(page, page_count)
)
- )
diff --git a/src/pdf/page_labels.py b/src/pdf/page_labels.py
new file mode 100644
index 0000000..23a87f8
--- /dev/null
+++ b/src/pdf/page_labels.py
@@ -0,0 +1,54 @@
+"""Page-label policy for an exported PDF.
+
+Page labels name physical pages in a reader; outlines still target page indexes.
+"""
+
+from dataclasses import dataclass
+from typing import Optional
+
+from pypdf.constants import PageLabelStyle
+from pypdf.generic import NameObject
+
+
+@dataclass(frozen=True)
+class PageLabelPlan:
+ mode: str = "preserve"
+ body_start_page: Optional[int] = None # One-based physical PDF page.
+
+ def validate(self, page_count):
+ if self.mode == "preserve":
+ if self.body_start_page is not None:
+ raise ValueError("Body start page requires Roman/body page labels")
+ return
+ if self.mode != "roman-body":
+ raise ValueError("Unknown page-label mode: {}".format(self.mode))
+ if not isinstance(self.body_start_page, int) or isinstance(
+ self.body_start_page, bool
+ ) or not 1 <= self.body_start_page <= page_count:
+ raise ValueError(
+ "Body start page must be between 1 and {}".format(page_count)
+ )
+
+
+def apply_page_labels(reader, writer, plan):
+ """Copy existing labels or create Roman front matter and decimal body labels."""
+ plan.validate(len(writer.pages))
+ if plan.mode == "preserve":
+ labels = reader.trailer["/Root"].get("/PageLabels")
+ if labels is not None:
+ # Clone the number tree into the destination writer. Keeping its raw
+ # structure preserves prefixes, numbering styles and extra ranges.
+ writer._root_object[NameObject("/PageLabels")] = labels.clone(writer)
+ return
+
+ first_body_index = plan.body_start_page - 1
+ if first_body_index:
+ writer.set_page_label(
+ 0, first_body_index - 1, style=PageLabelStyle.LOWERCASE_ROMAN, start=1
+ )
+ writer.set_page_label(
+ first_body_index,
+ len(writer.pages) - 1,
+ style=PageLabelStyle.DECIMAL,
+ start=1,
+ )
diff --git a/src/pdf/pdf.py b/src/pdf/pdf.py
index facf2c4..ee5ef07 100644
--- a/src/pdf/pdf.py
+++ b/src/pdf/pdf.py
@@ -11,10 +11,14 @@
import logging
import os
+import secrets
+import stat
from pypdf import PageObject, PdfReader, PdfWriter
from pypdf.generic import Destination, Fit
+from .page_labels import PageLabelPlan, apply_page_labels
+
logger = logging.getLogger(__name__)
@@ -42,12 +46,14 @@ class Pdf(object):
"""
- def __init__(self, path, keep_outline=False):
+ def __init__(self, path, keep_outline=False, page_label_plan=None):
self.path = path
- self.reader = PdfReader(open(path, "rb"), strict=False)
+ self._source_stat = os.stat(path)
+ self.reader = PdfReader(path, strict=False)
self.pages_num = self._get_pages_num(self.reader.pages)
self._writer = None
self.keep_outline = keep_outline
+ self.page_label_plan = page_label_plan or PageLabelPlan()
@property
def _new_path(self):
@@ -69,6 +75,7 @@ def writer(self):
# when adding bookmarks to some pdf which already have outline
if not self.keep_outline:
writer._root_object.pop("/Outlines", None)
+ apply_page_labels(self.reader, writer, self.page_label_plan)
self._writer = writer
return self._writer
@@ -198,8 +205,51 @@ def add_bookmark(self, title, pagenum, parent=None):
def save_pdf(self):
"""save the writer to a pdf file with name 'name_new.pdf'"""
- if os.path.exists(self._new_path):
- os.remove(self._new_path)
- with open(self._new_path, "wb") as out:
- self.writer.write(out)
+ writer = self.writer
+ temp_path = os.path.join(
+ os.path.dirname(self._new_path) or ".",
+ ".pdfdir-{}.pdf".format(secrets.token_hex(16)),
+ )
+ fd = os.open(
+ temp_path,
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
+ 0o666,
+ )
+ try:
+ with os.fdopen(fd, "wb") as out:
+ writer.write(out)
+ # Finish all checks while the read handle is open, then close it
+ # before replacing the destination (including on Windows).
+ with open(temp_path, "rb") as temp_input:
+ output = PdfReader(temp_input)
+ if len(output.pages) != len(self.reader.pages):
+ raise ValueError("Exported PDF has a different page count")
+ if self.page_label_plan.mode == "preserve":
+ if self.reader.trailer["/Root"].get("/PageLabels") is not None:
+ if output.page_labels != self.reader.page_labels:
+ raise ValueError("Exported PDF lost its existing page labels")
+ elif output.page_labels[self.page_label_plan.body_start_page - 1] != "1":
+ raise ValueError("Exported PDF has incorrect body page labels")
+ current_stat = os.stat(self.path)
+ if (
+ current_stat.st_ino,
+ current_stat.st_size,
+ current_stat.st_mtime_ns,
+ ) != (
+ self._source_stat.st_ino,
+ self._source_stat.st_size,
+ self._source_stat.st_mtime_ns,
+ ):
+ raise ValueError("Source PDF changed during export; please retry")
+ if os.name != "nt" and os.path.exists(self._new_path):
+ # New exports already have the user's umask; replacements keep
+ # the previous output's mode instead of the temporary mode.
+ os.chmod(
+ temp_path,
+ stat.S_IMODE(os.stat(self._new_path).st_mode),
+ )
+ os.replace(temp_path, self._new_path)
+ finally:
+ if os.path.exists(temp_path):
+ os.unlink(temp_path)
return self._new_path
diff --git a/src/pdfdirectory.py b/src/pdfdirectory.py
index 5b6ebf3..d3c887a 100644
--- a/src/pdfdirectory.py
+++ b/src/pdfdirectory.py
@@ -15,8 +15,9 @@ def add_directory(
level4=None,
level5=None,
other=0,
+ page_label_plan=None,
):
index_dict = convert_dir_text(
dir_text, offset, level0, level1, level2, level3, level4, level5, other=other
)
- return add_bookmark(pdf_path, index_dict)
+ return add_bookmark(pdf_path, index_dict, page_label_plan=page_label_plan)
diff --git a/tests/test_cli_page_labels.py b/tests/test_cli_page_labels.py
new file mode 100644
index 0000000..d9400b4
--- /dev/null
+++ b/tests/test_cli_page_labels.py
@@ -0,0 +1,32 @@
+"""CLI argument errors should be reported without a Python traceback."""
+
+import subprocess
+import sys
+from pathlib import Path
+
+from pypdf import PdfWriter
+
+
+def test_body_start_beyond_pdf_is_argument_error(tmp_path):
+ source = tmp_path / "book.pdf"
+ toc = tmp_path / "contents.txt"
+ writer = PdfWriter()
+ writer.add_blank_page(width=72, height=72)
+ with source.open("wb") as output:
+ writer.write(output)
+ toc.write_text("Chapter 1", encoding="utf-8")
+
+ result = subprocess.run(
+ [
+ sys.executable, "run_cli.py", str(source), str(toc),
+ "--page-labels", "roman-body", "--body-start-page", "2",
+ ],
+ cwd=str(Path(__file__).resolve().parents[1]),
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 2
+ assert "Body start page must be between 1 and 1" in result.stderr
+ assert "Traceback" not in result.stderr
+ assert not (tmp_path / "book_new.pdf").exists()
diff --git a/tests/test_gui_pdf_switch.py b/tests/test_gui_pdf_switch.py
new file mode 100644
index 0000000..5d93d8c
--- /dev/null
+++ b/tests/test_gui_pdf_switch.py
@@ -0,0 +1,169 @@
+"""Exercise PDF switching in a separate Qt process (avoids pytest/Qt teardown)."""
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from pypdf import PdfWriter
+
+
+def test_manual_pdf_switch_guards_draft_and_export_feedback(tmp_path):
+ first = tmp_path / "first.pdf"
+ second = tmp_path / "second.pdf"
+ for path in (first, second):
+ writer = PdfWriter()
+ for _ in range(8):
+ writer.add_blank_page(width=72, height=72)
+ with path.open("wb") as output:
+ writer.write(output)
+
+ script = """
+import sys
+from pathlib import Path
+from PyQt5 import QtCore, QtWidgets
+from pypdf import PdfReader
+from src.gui.main import Main
+
+app = QtWidgets.QApplication([])
+window = Main(app, QtCore.QTranslator())
+first, second = sys.argv[1:]
+window.pdf_path_edit.setText(first)
+window.pdf_path_edit.editingFinished.emit()
+window.dir_text_edit.setPlainText('First Chapter 1')
+window.offset_edit.setText('3')
+window.page_label_mode.setCurrentIndex(1)
+
+def choose(label):
+ def exec_dialog(box):
+ for button in box.buttons():
+ if button.text() == label:
+ button.click()
+ return 0
+ raise AssertionError('Missing dialog button: ' + label)
+ QtWidgets.QMessageBox.exec_ = exec_dialog
+
+choose('取消')
+window.pdf_path_edit.setText(second)
+window.pdf_path_edit.editingFinished.emit()
+assert window.pdf_path == first
+assert window.dir_text == 'First Chapter 1'
+assert window.page_label_plan.body_start_page == 4
+
+choose('放弃修改')
+window.pdf_path_edit.setText(second)
+window.pdf_path_edit.editingFinished.emit()
+assert window.pdf_path == second
+assert window.dir_text == ''
+assert window.page_label_plan.mode == 'preserve'
+
+window.dir_text_edit.setPlainText('Second Chapter 1')
+window.offset_edit.setText('2')
+window.page_label_mode.setCurrentIndex(1)
+window.alert_msg = lambda *args, **kwargs: None
+assert window.write_tree_to_pdf() is True
+assert window.statusbar.currentMessage().startswith('已导出:')
+output = PdfReader(str(Path(second).with_name('second_new.pdf')))
+assert output.page_labels[:4] == ['i', 'ii', '1', '2']
+assert output.outline[0].title == 'Second Chapter'
+assert output.get_destination_page_number(output.outline[0]) == 2
+
+window.offset_edit.setText('100')
+assert window.write_tree_to_pdf() is False
+assert window.statusbar.currentMessage().startswith('导出失败:')
+window.offset_edit.setText('2')
+window.dir_text_edit.setPlainText('Second Updated 1')
+
+def export_and_replace(box):
+ for button in box.buttons():
+ if button.text() in ('导出当前 PDF', '替换'):
+ button.click()
+ return 0
+ raise AssertionError('Missing export or replace button')
+
+QtWidgets.QMessageBox.exec_ = export_and_replace
+window.pdf_path_edit.setText(first)
+window.pdf_path_edit.editingFinished.emit()
+assert window.pdf_path == first
+assert window.dir_text == ''
+assert PdfReader(str(Path(second).with_name('second_new.pdf'))).outline[0].title == 'Second Updated'
+window.close()
+"""
+ env = dict(os.environ, QT_QPA_PLATFORM="offscreen")
+ result = subprocess.run(
+ [sys.executable, "-c", script, str(first), str(second)],
+ cwd=str(Path(__file__).resolve().parents[1]),
+ env=env,
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, result.stdout + result.stderr
+
+
+def test_invalid_page_edit_still_guards_draft_and_translates_controls(tmp_path):
+ first = tmp_path / "first.pdf"
+ second = tmp_path / "second.pdf"
+ for path in (first, second):
+ writer = PdfWriter()
+ for _ in range(3):
+ writer.add_blank_page(width=72, height=72)
+ with path.open("wb") as output:
+ writer.write(output)
+
+ script = """
+import sys
+from PyQt5 import QtCore, QtWidgets
+from src.gui.main import Main
+sys.excepthook = sys.__excepthook__
+
+app = QtWidgets.QApplication([])
+window = Main(app, QtCore.QTranslator())
+first, second = sys.argv[1:]
+window.pdf_path_edit.setText(first)
+window.pdf_path_edit.editingFinished.emit()
+window.dir_text_edit.setPlainText('Chapter 1')
+item = window.dir_tree_widget.topLevelItem(0)
+assert item is not None
+item.setText(2, 'invalid')
+assert window._has_unsaved_draft()
+
+def cancel_switch(box):
+ for button in box.buttons():
+ if button.text() == '取消':
+ button.click()
+ return 0
+ raise AssertionError('Missing cancel button')
+
+QtWidgets.QMessageBox.exec_ = cancel_switch
+window.pdf_path_edit.setText(second)
+window.pdf_path_edit.editingFinished.emit()
+assert window.pdf_path == first
+assert item.text(2) == 'invalid'
+
+window.page_label_mode.setCurrentIndex(1)
+window.offset_edit.setText('-2')
+assert '取消勾选' in window.page_label_summary.text()
+window.page_label_auto.setChecked(False)
+window.body_start_page.setValue(2)
+assert window.page_label_plan.body_start_page == 2
+window.dir_tree_widget.topLevelItem(0).setText(2, 'invalid')
+
+window.to_english()
+assert window.page_label_group.title() == 'Reader page numbers'
+assert window.page_label_auto.text() == 'Use page offset'
+assert window.tr('替换已有文件?\\n{}') == 'Replace the existing file?\\n{}'
+assert window.body_start_page.value() == 2
+assert window.dir_tree_widget.topLevelItem(0).text(2) == 'invalid'
+window.to_chinese()
+assert window.page_label_group.title() == '阅读器页码'
+window.close()
+"""
+ result = subprocess.run(
+ [sys.executable, "-c", script, str(first), str(second)],
+ cwd=str(Path(__file__).resolve().parents[1]),
+ env=dict(os.environ, QT_QPA_PLATFORM="offscreen"),
+ capture_output=True,
+ text=True,
+ timeout=20,
+ )
+ assert result.returncode == 0, result.stdout + result.stderr
diff --git a/tests/test_page_labels.py b/tests/test_page_labels.py
new file mode 100644
index 0000000..a42fcbd
--- /dev/null
+++ b/tests/test_page_labels.py
@@ -0,0 +1,136 @@
+"""An export writes bookmarks and reader page labels to the same PDF."""
+
+import os
+import stat
+from pathlib import Path
+
+import pytest
+from pypdf import PdfReader, PdfWriter
+from pypdf.constants import PageLabelStyle
+
+from src.pdf import pdf as pdf_module
+from src.pdf.bookmark import add_bookmark, check_bookmarks
+from src.pdf.page_labels import PageLabelPlan
+
+
+def make_source(tmp_path, labeled=False):
+ path = Path(tmp_path) / "book.pdf"
+ writer = PdfWriter()
+ for _ in range(6):
+ writer.add_blank_page(width=72, height=72)
+ if labeled:
+ writer.set_page_label(0, 2, style=PageLabelStyle.LOWERCASE_ROMAN, start=1)
+ writer.set_page_label(3, 5, style=PageLabelStyle.DECIMAL, prefix="Body-", start=1)
+ with path.open("wb") as output:
+ writer.write(output)
+ return path
+
+
+def test_export_writes_bookmark_and_roman_body_labels(tmp_path):
+ source = make_source(tmp_path)
+ output = add_bookmark(
+ str(source),
+ {0: {"title": "Chapter One", "real_num": 4}},
+ page_label_plan=PageLabelPlan("roman-body", 4),
+ )
+ reader = PdfReader(output)
+ assert reader.page_labels == ["i", "ii", "iii", "1", "2", "3"]
+ assert reader.get_destination_page_number(reader.outline[0]) == 3
+
+
+def test_export_preserves_existing_custom_labels_by_default(tmp_path):
+ source = make_source(tmp_path, labeled=True)
+ output = add_bookmark(str(source), {0: {"title": "Chapter One", "real_num": 4}})
+ reader = PdfReader(output)
+ assert reader.page_labels == ["i", "ii", "iii", "Body-1", "Body-2", "Body-3"]
+ assert reader.get_destination_page_number(reader.outline[0]) == 3
+
+
+@pytest.mark.parametrize("body_start", [0, 7, -1])
+def test_invalid_start_does_not_replace_previous_export(tmp_path, body_start):
+ source = make_source(tmp_path)
+ output_path = Path(str(source).replace(".pdf", "_new.pdf"))
+ output_path.write_bytes(b"existing output")
+ with pytest.raises(ValueError, match="Body start page"):
+ add_bookmark(
+ str(source),
+ {0: {"title": "Chapter One", "real_num": 4}},
+ page_label_plan=PageLabelPlan("roman-body", body_start),
+ )
+ assert output_path.read_bytes() == b"existing output"
+
+
+def test_bookmark_page_validation_rejects_zero(tmp_path):
+ source = make_source(tmp_path)
+ with pytest.raises(ValueError, match="Bookmark page"):
+ add_bookmark(str(source), {0: {"title": "Bad", "real_num": 0}})
+
+
+def test_bookmark_page_validation_rejects_bool(tmp_path):
+ source = make_source(tmp_path)
+ with pytest.raises(ValueError, match="Bookmark page"):
+ add_bookmark(str(source), {0: {"title": "Bad", "real_num": True}})
+
+
+def test_bookmark_validation_can_reuse_known_page_count(tmp_path):
+ check_bookmarks(
+ str(tmp_path / "not-opened.pdf"),
+ {0: {"title": "Chapter", "real_num": 4}},
+ page_count=6,
+ )
+
+
+@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
+def test_export_retains_file_permissions(tmp_path):
+ source = make_source(tmp_path)
+ source.chmod(0o666)
+ previous_umask = os.umask(0o027)
+ try:
+ output = Path(add_bookmark(str(source), {}))
+ finally:
+ os.umask(previous_umask)
+ assert stat.S_IMODE(output.stat().st_mode) == 0o640
+
+ output.chmod(0o600)
+ add_bookmark(str(source), {})
+ assert stat.S_IMODE(output.stat().st_mode) == 0o600
+
+
+def test_export_closes_validation_handle_before_replace(tmp_path, monkeypatch):
+ source = make_source(tmp_path)
+ original_reader = pdf_module.PdfReader
+ original_replace = os.replace
+ validation_streams = []
+
+ def tracked_reader(stream, *args, **kwargs):
+ if hasattr(stream, "name") and Path(stream.name).name.startswith(".pdfdir-"):
+ validation_streams.append(stream)
+ return original_reader(stream, *args, **kwargs)
+
+ def checked_replace(src, dst):
+ assert validation_streams and validation_streams[-1].closed
+ return original_replace(src, dst)
+
+ monkeypatch.setattr(pdf_module, "PdfReader", tracked_reader)
+ monkeypatch.setattr(pdf_module.os, "replace", checked_replace)
+ output = add_bookmark(str(source), {})
+ assert PdfReader(output).pages
+
+
+def test_failed_write_keeps_previous_export_and_removes_temp_file(tmp_path, monkeypatch):
+ source = make_source(tmp_path)
+ previous = tmp_path / "book_new.pdf"
+ previous.write_bytes(b"previous export")
+
+ def fail_write(*args, **kwargs):
+ raise OSError("disk full")
+
+ monkeypatch.setattr(PdfWriter, "write", fail_write)
+ with pytest.raises(OSError, match="disk full"):
+ add_bookmark(
+ str(source),
+ {0: {"title": "Chapter One", "real_num": 4}},
+ page_label_plan=PageLabelPlan("roman-body", 4),
+ )
+ assert previous.read_bytes() == b"previous export"
+ assert list(tmp_path.glob(".pdfdir-*")) == []