From cdbe01714fb97db53017b338e9f2dd7e0b1f99dd Mon Sep 17 00:00:00 2001 From: chroming Date: Fri, 25 Sep 2026 13:47:29 +0800 Subject: [PATCH 1/4] feat: write Roman front matter and body page labels with outlines --- readme.md | 28 ++--- run_cli.py | 21 +++- src/gui/main.py | 232 ++++++++++++++++++++++++++++++++++++-- src/pdf/bookmark.py | 23 ++-- src/pdf/page_labels.py | 54 +++++++++ src/pdf/pdf.py | 45 +++++++- src/pdfdirectory.py | 3 +- tests/test_page_labels.py | 82 ++++++++++++++ 8 files changed, 438 insertions(+), 50 deletions(-) create mode 100644 src/pdf/page_labels.py create mode 100644 tests/test_page_labels.py 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..0a3332c 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,21 @@ 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 + toc, offset, pdfPath, args.l0, args.l1, args.l2, args.l3, args.l4, args.l5, + page_label_plan=label_plan, ) diff --git a/src/gui/main.py b/src/gui/main.py index ae1f787..57e1fb3 100644 --- a/src/gui/main.py +++ b/src/gui/main.py @@ -22,7 +22,9 @@ 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 @@ -108,8 +110,12 @@ def __init__(self, app, trans): self.app = app self.trans = trans self.setupUi(self) + self.setMinimumSize(760, 580) + self._pdf_page_count = 0 + self._page_label_language = "zh" 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 +135,116 @@ 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): + return ( + self.dir_text, + self.offset_edit.text(), + self.tree_to_dict(), + 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): + zh = self._page_label_language == "zh" + self.page_label_group.setTitle("阅读器页码" if zh else "Reader page numbers") + self.page_label_mode.blockSignals(True) + selected = self.page_label_mode.currentIndex() + self.page_label_mode.clear() + self.page_label_mode.addItems( + ["保留原文件页码", "前置页罗马,正文从 1 开始"] + if zh else ["Preserve source labels", "Roman front, body from 1"] + ) + self.page_label_mode.setCurrentIndex(max(selected, 0)) + self.page_label_mode.blockSignals(False) + self.page_label_auto.setText("根据页差" if zh else "Use page offset") + self.body_start_page.setPrefix("PDF 第 " if zh else "PDF page ") + self.body_start_page.setSuffix(" 页" if zh else "") + 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) + zh = getattr(self, "_page_label_language", "zh") == "zh" + if not generated: + msg = "导出时保留原 PDF 的页码规则" if zh else "Keep the source PDF page labels" + elif self.page_label_auto.isChecked() and self.offset_num < 0: + msg = "页差不能推导正文起始页,请手动指定" if zh else "Set body start manually for a negative offset" + 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 = "正文起始页超过 PDF 总页数" if zh else "Body start exceeds the PDF page count" + else: + start = self.body_start_page.value() + if zh: + msg = "PDF 第 1–{} 页:i…;第 {} 页起:1…".format(start - 1, start) if start > 1 else "PDF 第 1 页起:1…" + msg += ";将替换原有页码规则" + else: + msg = "PDF pages 1–{}: i…; page {} onward: 1…".format(start - 1, start) if start > 1 else "PDF page 1 onward: 1…" + msg += "; replaces source labels" + 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: + pass + 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 +322,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._refresh_pdf_page_count) def _set_action(self): self.home_page_action.triggered.connect(self._open_home_page) @@ -282,13 +403,46 @@ 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._page_label_language = "en" + self._translate_page_label_controls() def to_chinese(self): self.app.removeTranslator(self.trans) - self.retranslateUi(self) + self._retranslate_preserving_draft() + self._page_label_language = "zh" + 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 +507,43 @@ def open_file_dialog(self): filename, _ = QtWidgets.QFileDialog.getOpenFileName( self, "select PDF", directory=self.default_folder, filter="PDF (*.pdf)" ) + if not filename or filename == self.pdf_path: + return + previous_path = self.pdf_path + if previous_path and self._has_unsaved_draft(): + zh = self._page_label_language == "zh" + box = QMessageBox(self) + box.setWindowTitle("未导出的目录修改" if zh else "Unsaved directory edits") + box.setText( + "打开其他文件前,如何处理当前修改?" + if zh else "What should happen to the current edits?" + ) + export = box.addButton("导出当前 PDF" if zh else "Export current PDF", QMessageBox.AcceptRole) + discard = box.addButton("放弃修改" if zh else "Discard edits", QMessageBox.DestructiveRole) + cancel = box.addButton("取消" if zh else "Cancel", QMessageBox.RejectRole) + box.setDefaultButton(cancel) + box.exec_() + if box.clickedButton() == cancel: + return + if box.clickedButton() == export and not self.write_tree_to_pdf(): + return + if box.clickedButton() != discard and box.clickedButton() != export: + return 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.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() def tree_to_dict(self): return self.dir_tree_widget.to_dict() @@ -550,23 +733,48 @@ 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") + check_bookmarks(path, index_dict, self.keep_exist_dir) + self.page_label_plan.validate(len(PdfReader(path).pages)) def write_tree_to_pdf(self): 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): + zh = self._page_label_language == "zh" + box = QMessageBox(self) + box.setWindowTitle("替换导出的 PDF" if zh else "Replace exported PDF") + box.setText( + ("替换已有文件?\n{}" if zh else "Replace the existing file?\n{}").format( + output_path + ) + ) + replace = box.addButton("替换" if zh else "Replace", QMessageBox.DestructiveRole) + cancel = box.addButton("取消" if zh else "Cancel", QMessageBox.RejectRole) + box.setDefaultButton(cancel) + box.exec_() + if box.clickedButton() != replace: + return False + self.export_button.setEnabled(False) + self.show_status("Writing PDF..." if self._page_label_language == "en" else "正在写入 PDF…") + QtWidgets.QApplication.processEvents() + new_path = self.dict_to_pdf( + self.pdf_path, index_dict, self.keep_exist_dir, self.page_label_plan + ) 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 Exception as exc: + 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/pdf/bookmark.py b/src/pdf/bookmark.py index 6bba6f9..d7cac37 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, keep_exist_dir) _add_bookmark(pdf, index_dict) return pdf.save_pdf() @@ -80,12 +83,12 @@ def get_bookmarks(path): def check_bookmarks(path, index_dict, keep_exist_dir=False): 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 + from pypdf import PdfReader + + max_page_num = len(PdfReader(path).pages) + for value in index_dict.values(): + page = value.get("real_num", 1) + if not isinstance(page, int) or not 1 <= page <= max_page_num: + raise ValueError( + "Bookmark page '{}' must be between 1 and {}".format(page, max_page_num) ) - ) 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..abd245f 100644 --- a/src/pdf/pdf.py +++ b/src/pdf/pdf.py @@ -11,10 +11,13 @@ import logging import os +import tempfile 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 +45,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 +74,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 +204,35 @@ 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 + fd, temp_path = tempfile.mkstemp( + prefix=".pdfdir-", suffix=".pdf", dir=os.path.dirname(self._new_path) or "." + ) + try: + with os.fdopen(fd, "wb") as out: + writer.write(out) + output = PdfReader(temp_path) + 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") + 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_page_labels.py b/tests/test_page_labels.py new file mode 100644 index 0000000..6509551 --- /dev/null +++ b/tests/test_page_labels.py @@ -0,0 +1,82 @@ +"""An export writes bookmarks and reader page labels to the same PDF.""" + +from pathlib import Path + +import pytest +from pypdf import PdfReader, PdfWriter +from pypdf.constants import PageLabelStyle + +from src.pdf.bookmark import add_bookmark +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_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-*")) == [] From ce4cec527062a735b872071d2134e2b462d29a79 Mon Sep 17 00:00:00 2001 From: chroming Date: Fri, 25 Sep 2026 14:42:44 +0800 Subject: [PATCH 2/4] fix(gui): guard manual PDF switching and finish export status --- src/gui/main.py | 48 ++++++++++++++--- tests/test_gui_pdf_switch.py | 100 +++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 tests/test_gui_pdf_switch.py diff --git a/src/gui/main.py b/src/gui/main.py index 57e1fb3..2b29f20 100644 --- a/src/gui/main.py +++ b/src/gui/main.py @@ -110,6 +110,7 @@ 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._page_label_language = "zh" @@ -326,7 +327,7 @@ def _set_connect(self): 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._refresh_pdf_page_count) + 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) @@ -507,9 +508,24 @@ def open_file_dialog(self): filename, _ = QtWidgets.QFileDialog.getOpenFileName( self, "select PDF", directory=self.default_folder, filter="PDF (*.pdf)" ) - if not filename or filename == self.pdf_path: - return - previous_path = self.pdf_path + 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(): zh = self._page_label_language == "zh" box = QMessageBox(self) @@ -524,16 +540,18 @@ def open_file_dialog(self): box.setDefaultButton(cancel) box.exec_() if box.clickedButton() == cancel: - return + return False if box.clickedButton() == export and not self.write_tree_to_pdf(): - return + return False if box.clickedButton() != discard and box.clickedButton() != export: - return - self.default_folder = os.path.dirname(filename) + 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() @@ -544,6 +562,7 @@ def open_file_dialog(self): 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() @@ -737,6 +756,9 @@ def pre_check(self, path, index_dict): self.page_label_plan.validate(len(PdfReader(path).pages)) 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) @@ -763,10 +785,20 @@ def write_tree_to_pdf(self): new_path = self.dict_to_pdf( self.pdf_path, index_dict, self.keep_exist_dir, self.page_label_plan ) + self.show_status( + ("Exported: " if self._page_label_language == "en" else "已导出:") + + new_path, + 5000, + ) self.alert_msg("%s Finished!" % new_path) self._loaded_draft = self._draft_snapshot() return True except Exception as exc: + self.show_status( + ("Export failed: " if self._page_label_language == "en" else "导出失败:") + + str(exc), + 5000, + ) self.alert_msg(str(exc), level="warn") return False finally: diff --git a/tests/test_gui_pdf_switch.py b/tests/test_gui_pdf_switch.py new file mode 100644 index 0000000..5f69977 --- /dev/null +++ b/tests/test_gui_pdf_switch.py @@ -0,0 +1,100 @@ +"""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 From a98fd913fcfdab7547266dfff7aa657738e32b9e Mon Sep 17 00:00:00 2001 From: chroming Date: Fri, 25 Sep 2026 17:50:08 +0800 Subject: [PATCH 3/4] Address page-label PR review feedback --- run_cli.py | 11 ++-- src/gui/en.qm | Bin 1093 -> 3526 bytes src/gui/en.ts | 27 +++++++++ src/gui/main.py | 103 +++++++++++++++++----------------- src/language/en.qm | Bin 1093 -> 3526 bytes src/pdf/bookmark.py | 17 ++++-- src/pdf/pdf.py | 20 ++++++- tests/test_cli_page_labels.py | 32 +++++++++++ tests/test_gui_pdf_switch.py | 69 +++++++++++++++++++++++ tests/test_page_labels.py | 34 ++++++++++- 10 files changed, 246 insertions(+), 67 deletions(-) create mode 100644 tests/test_cli_page_labels.py diff --git a/run_cli.py b/run_cli.py index 0a3332c..0f3575d 100644 --- a/run_cli.py +++ b/run_cli.py @@ -71,7 +71,10 @@ 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, - page_label_plan=label_plan, - ) + 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 0d9a14f811d9fb01fd0816441ec91b1030a8feac..eb0f5425af867bdd20c30b9c61c62518f55eb5b9 100644 GIT binary patch literal 3526 zcma)8ZA?>V6g~wk1+J};4bc#CjfUxhhQ?%BaDK$8bLb4ts9D?(v4C`zf~{^dj;xz} zhhY2&&PF}_4n=b;P;lTWkDChF0)1ETZz(oQWCcYiBk8a z>?qB_{nC^NyVnEPm6ouw0C7J}%U-b<`1hsd%jd%XS7~$JBgDBY?Y&t@l(bhGZ^;6V zZKi$nI&jY0&)vt>deuI<4|XXx?4wnG;`+!wz9jKL859fz;o zXxipPoH9tJMN~;;bddbyCKnZvhZfuL6QN>ClE=^8PUgcqMQ*Q!U1n1ut)=yFtc39% zDj``nw-v5kFNb@3Wuwch@h;>9Km~c}BY^m*nDX)6Dd*@=bEcr2oI>RG;6(7W^GV>c z5qVP_vR~k&(;=!7m~J_Tmd7S5etB{1JU#Z4!0|&>oB*P`5q%=q!R2Z9{w=9oB;`Y2 z{P^?Hhuk5YE9d~exx_aQir$Trhx~fMFOQ9U5pHc)n$N0jbz{Q=2paDC%vf$7y$hUj zczc0a3OsJ%e#*tS-8dIxj8pf^adPVD3zhn&aHx+#Lnp_3dMAR7>a`0ZQnT!om0&=* z(qIfG(VHR=r|i*ztV&CpI&j2jnoaBIWALE@8SNJt6oE`0IEumQ&JLwNsMOt6F5ZaT zx`dV*Q5Kp|T%u##Nv=`WMy+Gbn#2knM`9^tMqm)Q|piOlxqgYhTAlTp>Lppa{2SIP^)sWI()2F5zQPM z=~GYKnFyXW7BvrR?*=Ao1aGX2Tqfh_Q6%-mJtcTpLomAMgHF+Myg2hrdAdacEPfm= zVUOAC@N?9YUFuh@k^W(@P>caHP+PAXBiXSfJa`@_$g6u>)s7Lh>AcZ+kGS3ejMf$152iifd%?pKA60;nM<510m#4KO`R+sELf!31X>%;pw=QBSy=NWo?gbix;a} zE>ztYqw0?FQ#DN2_HLlX>G=m@dM+VPF4_yc7q(mIyjCBJfEWR;QRR>Ai}sW_fIfNf zIjDDfi#DXOp{B^;klNf07MQw#hnyzzO2fVJK>gD|W9)RF2AY&(&1!vva!DjuT9}RJ>8}-2>#0o|z1LkG0N3_1GT5)&t2SdKZC+ARr(0Xc zhOWWdY&45DfYjxpS>k0X)`1CVcCc#mjG8Gm-Q$P5&1R|k(#6Q1wp?b@?5HWY6sI0~ zBuR7Ew89KxLmm7&Q>mLGrJL=glsKPpZ{#o%ZXa*ueTw+vCbH*Ii&R%n(vuG2NWJ>bR;k`K8ib(&p zNO!>OAc+O$mn2Aa)EFBFu9%H8Xsc+jrtA7+LwA$lrZ)6wgfu(b&_Pp5pw{GbGq!V? cFe}CmeUzi#W=D#|srPfHo>i3DpRjQ6T_*+xh6#+v`y3b;m_r%wze@q~V_7{;GXZG> zju(n-3=B-~Ihlj^FfcG~=kYVR0TgH8dD$-m6!+uhd$0j0{(|p8AxQi?--Ft(K)xK` zxAUn$+LNDo|8t=DLVlLTM}hP)eqrr%oVW2$IM1ETb RAkD0k2_&X$*5;bc3;^p>G4lWb 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 2b29f20..841a12b 100644 --- a/src/gui/main.py +++ b/src/gui/main.py @@ -5,6 +5,7 @@ """ +import logging import os import sys import traceback @@ -28,6 +29,8 @@ # 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) @@ -113,7 +116,6 @@ def __init__(self, app, trans): self._active_pdf_path = self.pdf_path_edit.text() self.setMinimumSize(760, 580) self._pdf_page_count = 0 - self._page_label_language = "zh" self._init_auto_offset_button() self._init_auto_toc_button() self._init_page_label_controls() @@ -140,10 +142,20 @@ def __init__(self, app, trans): 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(), - self.tree_to_dict(), + tuple(tree), self.page_label_mode.currentIndex(), self.page_label_auto.isChecked(), self.body_start_page.value(), @@ -180,20 +192,18 @@ def _init_page_label_controls(self): self._translate_page_label_controls() def _translate_page_label_controls(self): - zh = self._page_label_language == "zh" - self.page_label_group.setTitle("阅读器页码" if zh else "Reader page numbers") + 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( - ["保留原文件页码", "前置页罗马,正文从 1 开始"] - if zh else ["Preserve source labels", "Roman front, body from 1"] + [self.tr("保留原文件页码"), self.tr("前置页罗马,正文从 1 开始")] ) self.page_label_mode.setCurrentIndex(max(selected, 0)) self.page_label_mode.blockSignals(False) - self.page_label_auto.setText("根据页差" if zh else "Use page offset") - self.body_start_page.setPrefix("PDF 第 " if zh else "PDF page ") - self.body_start_page.setSuffix(" 页" if zh else "") + 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): @@ -209,24 +219,23 @@ def _update_page_label_summary(self): self.body_start_page.blockSignals(True) self.body_start_page.setValue(suggested) self.body_start_page.blockSignals(False) - zh = getattr(self, "_page_label_language", "zh") == "zh" if not generated: - msg = "导出时保留原 PDF 的页码规则" if zh else "Keep the source PDF page labels" + msg = self.tr("导出时保留原 PDF 的页码规则") elif self.page_label_auto.isChecked() and self.offset_num < 0: - msg = "页差不能推导正文起始页,请手动指定" if zh else "Set body start manually for a negative offset" + 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 = "正文起始页超过 PDF 总页数" if zh else "Body start exceeds the PDF page count" + msg = self.tr("正文起始页超过 PDF 总页数") else: start = self.body_start_page.value() - if zh: - msg = "PDF 第 1–{} 页:i…;第 {} 页起:1…".format(start - 1, start) if start > 1 else "PDF 第 1 页起:1…" - msg += ";将替换原有页码规则" - else: - msg = "PDF pages 1–{}: i…; page {} onward: 1…".format(start - 1, start) if start > 1 else "PDF page 1 onward: 1…" - msg += "; replaces source labels" + 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): @@ -235,7 +244,8 @@ def _refresh_pdf_page_count(self): try: self._pdf_page_count = len(PdfReader(self.pdf_path).pages) except Exception: - pass + 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 @@ -408,13 +418,11 @@ def to_english(self): self.trans.load(os.path.join(os.path.dirname(__file__), "..", "language", "en.qm")) self.app.installTranslator(self.trans) self._retranslate_preserving_draft() - self._page_label_language = "en" self._translate_page_label_controls() def to_chinese(self): self.app.removeTranslator(self.trans) self._retranslate_preserving_draft() - self._page_label_language = "zh" self._translate_page_label_controls() def _retranslate_preserving_draft(self): @@ -527,16 +535,12 @@ def _open_pdf(self, filename): return True previous_path = self._active_pdf_path if previous_path and self._has_unsaved_draft(): - zh = self._page_label_language == "zh" box = QMessageBox(self) - box.setWindowTitle("未导出的目录修改" if zh else "Unsaved directory edits") - box.setText( - "打开其他文件前,如何处理当前修改?" - if zh else "What should happen to the current edits?" - ) - export = box.addButton("导出当前 PDF" if zh else "Export current PDF", QMessageBox.AcceptRole) - discard = box.addButton("放弃修改" if zh else "Discard edits", QMessageBox.DestructiveRole) - cancel = box.addButton("取消" if zh else "Cancel", QMessageBox.RejectRole) + 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: @@ -752,8 +756,9 @@ def closeEvent(self, event): super(Main, self).closeEvent(event) def pre_check(self, path, index_dict): - check_bookmarks(path, index_dict, self.keep_exist_dir) - self.page_label_plan.validate(len(PdfReader(path).pages)) + 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: @@ -765,40 +770,32 @@ def write_tree_to_pdf(self): name, ext = os.path.splitext(self.pdf_path) output_path = name + "_new" + ext if os.path.exists(output_path): - zh = self._page_label_language == "zh" box = QMessageBox(self) - box.setWindowTitle("替换导出的 PDF" if zh else "Replace exported PDF") - box.setText( - ("替换已有文件?\n{}" if zh else "Replace the existing file?\n{}").format( - output_path - ) - ) - replace = box.addButton("替换" if zh else "Replace", QMessageBox.DestructiveRole) - cancel = box.addButton("取消" if zh else "Cancel", QMessageBox.RejectRole) + 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("Writing PDF..." if self._page_label_language == "en" else "正在写入 PDF…") + 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( - ("Exported: " if self._page_label_language == "en" else "已导出:") - + new_path, - 5000, - ) + self.show_status(self.tr("已导出:") + new_path, 5000) self.alert_msg("%s Finished!" % new_path) 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: - self.show_status( - ("Export failed: " if self._page_label_language == "en" else "导出失败:") - + str(exc), - 5000, - ) + logger.exception("PDF export failed") + self.show_status(self.tr("导出失败:") + str(exc), 5000) self.alert_msg(str(exc), level="warn") return False finally: diff --git a/src/language/en.qm b/src/language/en.qm index 0d9a14f811d9fb01fd0816441ec91b1030a8feac..eb0f5425af867bdd20c30b9c61c62518f55eb5b9 100644 GIT binary patch literal 3526 zcma)8ZA?>V6g~wk1+J};4bc#CjfUxhhQ?%BaDK$8bLb4ts9D?(v4C`zf~{^dj;xz} zhhY2&&PF}_4n=b;P;lTWkDChF0)1ETZz(oQWCcYiBk8a z>?qB_{nC^NyVnEPm6ouw0C7J}%U-b<`1hsd%jd%XS7~$JBgDBY?Y&t@l(bhGZ^;6V zZKi$nI&jY0&)vt>deuI<4|XXx?4wnG;`+!wz9jKL859fz;o zXxipPoH9tJMN~;;bddbyCKnZvhZfuL6QN>ClE=^8PUgcqMQ*Q!U1n1ut)=yFtc39% zDj``nw-v5kFNb@3Wuwch@h;>9Km~c}BY^m*nDX)6Dd*@=bEcr2oI>RG;6(7W^GV>c z5qVP_vR~k&(;=!7m~J_Tmd7S5etB{1JU#Z4!0|&>oB*P`5q%=q!R2Z9{w=9oB;`Y2 z{P^?Hhuk5YE9d~exx_aQir$Trhx~fMFOQ9U5pHc)n$N0jbz{Q=2paDC%vf$7y$hUj zczc0a3OsJ%e#*tS-8dIxj8pf^adPVD3zhn&aHx+#Lnp_3dMAR7>a`0ZQnT!om0&=* z(qIfG(VHR=r|i*ztV&CpI&j2jnoaBIWALE@8SNJt6oE`0IEumQ&JLwNsMOt6F5ZaT zx`dV*Q5Kp|T%u##Nv=`WMy+Gbn#2knM`9^tMqm)Q|piOlxqgYhTAlTp>Lppa{2SIP^)sWI()2F5zQPM z=~GYKnFyXW7BvrR?*=Ao1aGX2Tqfh_Q6%-mJtcTpLomAMgHF+Myg2hrdAdacEPfm= zVUOAC@N?9YUFuh@k^W(@P>caHP+PAXBiXSfJa`@_$g6u>)s7Lh>AcZ+kGS3ejMf$152iifd%?pKA60;nM<510m#4KO`R+sELf!31X>%;pw=QBSy=NWo?gbix;a} zE>ztYqw0?FQ#DN2_HLlX>G=m@dM+VPF4_yc7q(mIyjCBJfEWR;QRR>Ai}sW_fIfNf zIjDDfi#DXOp{B^;klNf07MQw#hnyzzO2fVJK>gD|W9)RF2AY&(&1!vva!DjuT9}RJ>8}-2>#0o|z1LkG0N3_1GT5)&t2SdKZC+ARr(0Xc zhOWWdY&45DfYjxpS>k0X)`1CVcCc#mjG8Gm-Q$P5&1R|k(#6Q1wp?b@?5HWY6sI0~ zBuR7Ew89KxLmm7&Q>mLGrJL=glsKPpZ{#o%ZXa*ueTw+vCbH*Ii&R%n(vuG2NWJ>bR;k`K8ib(&p zNO!>OAc+O$mn2Aa)EFBFu9%H8Xsc+jrtA7+LwA$lrZ)6wgfu(b&_Pp5pw{GbGq!V? cFe}CmeUzi#W=D#|srPfHo>i3DpRjQ6T_*+xh6#+v`y3b;m_r%wze@q~V_7{;GXZG> zju(n-3=B-~Ihlj^FfcG~=kYVR0TgH8dD$-m6!+uhd$0j0{(|p8AxQi?--Ft(K)xK` zxAUn$+LNDo|8t=DLVlLTM}hP)eqrr%oVW2$IM1ETb RAkD0k2_&X$*5;bc3;^p>G4lWb diff --git a/src/pdf/bookmark.py b/src/pdf/bookmark.py index d7cac37..84944da 100644 --- a/src/pdf/bookmark.py +++ b/src/pdf/bookmark.py @@ -65,7 +65,7 @@ def add_bookmark(path, index_dict, keep_exist_dir=False, page_label_plan=None): pdf = Pdf( path, keep_outline=keep_exist_dir, page_label_plan=page_label_plan ) - check_bookmarks(path, index_dict, keep_exist_dir) + check_bookmarks(path, index_dict, page_count=len(pdf.reader.pages)) _add_bookmark(pdf, index_dict) return pdf.save_pdf() @@ -80,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 - from pypdf import PdfReader + if page_count is None: + from pypdf import PdfReader - max_page_num = len(PdfReader(path).pages) + page_count = len(PdfReader(path).pages) for value in index_dict.values(): page = value.get("real_num", 1) - if not isinstance(page, int) or not 1 <= page <= max_page_num: + 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, max_page_num) + "Bookmark page '{}' must be between 1 and {}".format(page, page_count) ) diff --git a/src/pdf/pdf.py b/src/pdf/pdf.py index abd245f..e10618f 100644 --- a/src/pdf/pdf.py +++ b/src/pdf/pdf.py @@ -11,7 +11,8 @@ import logging import os -import tempfile +import secrets +import stat from pypdf import PageObject, PdfReader, PdfWriter from pypdf.generic import Destination, Fit @@ -205,8 +206,14 @@ def add_bookmark(self, title, pagenum, parent=None): def save_pdf(self): """save the writer to a pdf file with name 'name_new.pdf'""" writer = self.writer - fd, temp_path = tempfile.mkstemp( - prefix=".pdfdir-", suffix=".pdf", dir=os.path.dirname(self._new_path) or "." + 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: @@ -231,6 +238,13 @@ def save_pdf(self): 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): 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 index 5f69977..5d93d8c 100644 --- a/tests/test_gui_pdf_switch.py +++ b/tests/test_gui_pdf_switch.py @@ -98,3 +98,72 @@ def export_and_replace(box): 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 index 6509551..579ea7d 100644 --- a/tests/test_page_labels.py +++ b/tests/test_page_labels.py @@ -1,12 +1,14 @@ """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.bookmark import add_bookmark +from src.pdf.bookmark import add_bookmark, check_bookmarks from src.pdf.page_labels import PageLabelPlan @@ -63,6 +65,36 @@ def test_bookmark_page_validation_rejects_zero(tmp_path): 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_failed_write_keeps_previous_export_and_removes_temp_file(tmp_path, monkeypatch): source = make_source(tmp_path) previous = tmp_path / "book_new.pdf" From b7ceda2de0d42fafd1771882e0cfda89708a1bb0 Mon Sep 17 00:00:00 2001 From: chroming Date: Fri, 25 Sep 2026 18:00:34 +0800 Subject: [PATCH 4/4] Close validation PDF before replacing export --- src/pdf/pdf.py | 21 ++++++++++++--------- tests/test_page_labels.py | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/pdf/pdf.py b/src/pdf/pdf.py index e10618f..ee5ef07 100644 --- a/src/pdf/pdf.py +++ b/src/pdf/pdf.py @@ -218,15 +218,18 @@ def save_pdf(self): try: with os.fdopen(fd, "wb") as out: writer.write(out) - output = PdfReader(temp_path) - 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") + # 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, diff --git a/tests/test_page_labels.py b/tests/test_page_labels.py index 579ea7d..a42fcbd 100644 --- a/tests/test_page_labels.py +++ b/tests/test_page_labels.py @@ -8,6 +8,7 @@ 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 @@ -95,6 +96,27 @@ def test_export_retains_file_permissions(tmp_path): 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"