repdf 0.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
repdf/app.py ADDED
@@ -0,0 +1,860 @@
1
+ from __future__ import annotations
2
+
3
+ from repdf.strings import tr, localize_text
4
+
5
+ import sys
6
+ import tempfile
7
+ from importlib.resources import files
8
+ from pathlib import Path
9
+
10
+ from PySide6.QtCore import QEvent, QSettings, QSize, Qt, QThreadPool, QTimer, Slot
11
+ from PySide6.QtGui import (
12
+ QCloseEvent,
13
+ QAction,
14
+ QDragEnterEvent,
15
+ QDragMoveEvent,
16
+ QDropEvent,
17
+ QFont,
18
+ QFontDatabase,
19
+ QIcon,
20
+ QImage,
21
+ QKeySequence,
22
+ )
23
+ from PySide6.QtWidgets import (
24
+ QApplication,
25
+ QCheckBox,
26
+ QAbstractButton,
27
+ QFileDialog,
28
+ QGridLayout,
29
+ QHBoxLayout,
30
+ QLabel,
31
+ QMainWindow,
32
+ QMessageBox,
33
+ QScrollArea,
34
+ QSizePolicy,
35
+ QSlider,
36
+ QToolBar,
37
+ QTabWidget,
38
+ QTabBar,
39
+ QToolButton,
40
+ QVBoxLayout,
41
+ QWidget,
42
+ )
43
+
44
+ from repdf.core import (
45
+ SelectionState,
46
+ default_output_path,
47
+ parity_state,
48
+ set_parity,
49
+ split_output_paths,
50
+ DocumentSelection,
51
+ )
52
+ from repdf import __version__
53
+ from repdf.icons import toolbar_icon
54
+ from repdf.theme import ThemeController
55
+ from repdf.preferences import LanguageController, migrate_preferences
56
+ from repdf.renderer import RenderService, start_render_backend
57
+ from repdf.widgets import PageCard, PageGrid
58
+ from repdf.workers import ExportPdfTask, LoadPdfTask
59
+
60
+
61
+ class DocumentView(QWidget):
62
+ REFERENCE_PAGE_WIDTH_POINTS = 595
63
+ SCREEN_DPI = 96
64
+ PDF_DPI = 72
65
+
66
+ def __init__(self, owner, document_id: int, path: Path) -> None:
67
+ super().__init__()
68
+ self.owner = owner
69
+ self.document_id = document_id
70
+ self.source_path = path.resolve()
71
+ self.page_count = 0
72
+ self.selected_pages = set()
73
+ self.page_order = []
74
+ self.page_cards = []
75
+ self.zoom = 100
76
+ self.ready = False
77
+ self.render_generation = -1
78
+ self._render_received = set()
79
+ self._render_pending = 0
80
+ self._render_done_generation = -1
81
+ self.include_checkbox = QCheckBox()
82
+ self.include_checkbox.setToolTip(tr('勾選此檔案以加入重組或拆分;未勾選任何標籤時僅輸出目前檔案'))
83
+ self.include_checkbox.setEnabled(False)
84
+ self.include_checkbox.toggled.connect(owner._sync_controls)
85
+ self.close_button = QToolButton()
86
+ self.close_button.setText("×")
87
+ self.close_button.setToolTip(tr('關閉此檔案'))
88
+ self.close_button.setFixedSize(22, 22)
89
+ self.close_button.clicked.connect(lambda: owner.close_tab(owner.tabs.indexOf(self)))
90
+ root = QVBoxLayout(self)
91
+ root.setContentsMargins(0, 0, 0, 0)
92
+ self.scroll_area = QScrollArea()
93
+ self.scroll_area.setWidgetResizable(True)
94
+ self.scroll_area.viewport().installEventFilter(self)
95
+ self.grid_host = PageGrid()
96
+ self.grid_host.insertion_requested.connect(self._insert_page)
97
+ self.grid_host.drag_finished.connect(self._reflow_grid)
98
+ self.grid = QGridLayout(self.grid_host)
99
+ self.grid.setContentsMargins(12, 12, 12, 12)
100
+ self.grid.setSpacing(12)
101
+ self.grid.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
102
+ self.empty_label = QLabel(tr('正在讀取 PDF…'))
103
+ self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
104
+ self.grid.addWidget(self.empty_label, 0, 0)
105
+ self.scroll_area.setWidget(self.grid_host)
106
+ root.addWidget(self.scroll_area)
107
+ self.render_timer = QTimer(self)
108
+ self.render_timer.setSingleShot(True)
109
+ self.render_timer.setInterval(250)
110
+ self.render_timer.timeout.connect(self._queue_all_renders)
111
+
112
+ def loaded(self, page_count: int) -> None:
113
+ self.page_count = page_count
114
+ self.ready = True
115
+ self.empty_label.hide()
116
+ self.grid.removeWidget(self.empty_label)
117
+ self._create_page_cards()
118
+ for card in self.page_cards:
119
+ card.checkbox.setEnabled(not self.owner._export_running)
120
+ self.include_checkbox.setEnabled(not self.owner._export_running)
121
+
122
+ def eventFilter(self, watched, event) -> bool:
123
+ if watched is self.scroll_area.viewport() and event.type() == QEvent.Type.Resize:
124
+ QTimer.singleShot(0, self._reflow_grid)
125
+ return super().eventFilter(watched, event)
126
+
127
+ def show_status(self, message: str) -> None:
128
+ if self.owner.current_document() is self and not self.owner._export_running:
129
+ self.owner.statusBar().showMessage(message)
130
+
131
+ def _create_page_cards(self) -> None:
132
+ self.page_order = list(range(self.page_count))
133
+ width = self._target_preview_width()
134
+ for page_index in range(self.page_count):
135
+ card = PageCard(page_index, width)
136
+ card.selection_changed.connect(self._page_selection_changed)
137
+ card.reorder_requested.connect(self._move_page)
138
+ self.page_cards.append(card)
139
+ self._reflow_grid()
140
+
141
+ def _target_preview_width(self) -> int:
142
+ return max(
143
+ 55,
144
+ round(self.REFERENCE_PAGE_WIDTH_POINTS * self._preview_scale()),
145
+ )
146
+
147
+ def _preview_scale(self) -> float:
148
+ return self.zoom / 100 * self.SCREEN_DPI / self.PDF_DPI
149
+
150
+ def _reflow_grid(self) -> None:
151
+ if self.grid_host.drag_source is not None:
152
+ return
153
+ if not self.page_cards:
154
+ return
155
+ while self.grid.count():
156
+ self.grid.takeAt(0)
157
+ card_width = max(card.width() for card in self.page_cards)
158
+ available = max(1, self.scroll_area.viewport().width() - 24)
159
+ columns = max(1, (available + self.grid.spacing()) // (card_width + self.grid.spacing()))
160
+ for position, page_index in enumerate(self.page_order):
161
+ card = self.page_cards[page_index]
162
+ self.grid.addWidget(card, position // columns, position % columns)
163
+ self.grid.activate()
164
+
165
+ @Slot(int, int)
166
+ def _insert_page(self, source_index: int, insertion: int) -> None:
167
+ if self.owner._export_running or source_index not in self.page_order:
168
+ return
169
+ self.page_order.remove(source_index)
170
+ self.page_order.insert(insertion, source_index)
171
+ self._reflow_grid()
172
+ self.show_status(
173
+ tr('已調整第 {p0} 頁的預覽與輸出順序', p0=source_index + 1)
174
+ )
175
+
176
+ @Slot(int, int)
177
+ def _move_page(self, source_index: int, target_index: int) -> None:
178
+ if self.owner._export_running or source_index == target_index:
179
+ return
180
+ if source_index not in self.page_order or target_index not in self.page_order:
181
+ return
182
+ source_position = self.page_order.index(source_index)
183
+ target_position = self.page_order.index(target_index)
184
+ self.page_order.pop(source_position)
185
+ insertion = self.page_order.index(target_index)
186
+ if source_position < target_position:
187
+ insertion += 1
188
+ self.page_order.insert(insertion, source_index)
189
+ self._reflow_grid()
190
+ self.show_status(
191
+ tr('已調整第 {p0} 頁的預覽與輸出順序', p0=source_index + 1)
192
+ )
193
+
194
+ @Slot(int, bool)
195
+ def _page_selection_changed(self, page_index: int, selected: bool) -> None:
196
+ if selected:
197
+ self.selected_pages.add(page_index)
198
+ else:
199
+ self.selected_pages.discard(page_index)
200
+ self._selection_updated()
201
+
202
+ def _toggle_parity(self, odd: bool, checked: bool) -> None:
203
+ if self.owner._syncing_bulk:
204
+ return
205
+ self.selected_pages = set_parity(
206
+ self.selected_pages, self.page_count, odd, checked
207
+ )
208
+ for card in self.page_cards:
209
+ card.set_selected(card.page_index in self.selected_pages)
210
+ self._selection_updated()
211
+
212
+ def _selection_updated(self) -> None:
213
+ self.owner._sync_controls()
214
+
215
+ def set_zoom(self, value: int) -> None:
216
+ if self.zoom == value:
217
+ return
218
+ self.zoom = value
219
+ if self.page_cards:
220
+ self.render_timer.start()
221
+
222
+ @Slot()
223
+ def _queue_all_renders(self) -> None:
224
+ if not self.ready or self.owner._closing:
225
+ return
226
+ self.owner._next_generation += 1
227
+ for generation, document_id in list(self.owner._render_documents.items()):
228
+ if document_id == self.document_id:
229
+ self.owner._render_documents.pop(generation)
230
+ self.render_generation = self.owner._next_generation
231
+ self.owner._render_documents[self.render_generation] = self.document_id
232
+ generation = self.render_generation
233
+ self.render_timer.stop()
234
+ self._render_pending = self.page_count
235
+ self._render_done_generation = -1
236
+ self._render_received.clear()
237
+ width = self._target_preview_width()
238
+ for card in self.page_cards:
239
+ card.set_target_width(width)
240
+ self._reflow_grid()
241
+ self.show_status(tr('正在產生頁面預覽…'))
242
+ self._reset_render_state()
243
+ self.owner.render_service.request(
244
+ self.source_path,
245
+ self._preview_scale(),
246
+ Path(self.owner.preview_temp.name),
247
+ generation,
248
+ document_id=self.document_id,
249
+ )
250
+
251
+ @Slot(object)
252
+ def _handle_render_message(self, payload: object) -> None:
253
+ if not isinstance(payload, dict):
254
+ return
255
+ kind = payload.get("type")
256
+ try:
257
+ generation = int(payload.get("generation", -1))
258
+ except (TypeError, ValueError):
259
+ return
260
+ if generation != self.render_generation:
261
+ return
262
+ if kind == "page":
263
+ self._accept_render_page(payload)
264
+ elif kind == "done":
265
+ self._render_done_generation = generation
266
+ self.show_status(
267
+ tr('已載入 {p0} 頁;已選取 {p1} 頁', p0=self.page_count, p1=len(self.selected_pages))
268
+ )
269
+ elif kind == "error":
270
+ for page_index, card in enumerate(self.page_cards):
271
+ if page_index not in self._render_received:
272
+ card.set_render_error()
273
+ self.show_status(tr('部分頁面無法產生預覽'))
274
+ QMessageBox.warning(
275
+ self.owner, tr('預覽失敗'), tr('無法完成頁面預覽:\n{p0}', p0=payload.get('message', ''))
276
+ )
277
+
278
+ def _accept_render_page(self, payload: dict[str, object]) -> None:
279
+ try:
280
+ page_index = int(payload["page"])
281
+ image_path = Path(payload["path"])
282
+ except (KeyError, TypeError, ValueError):
283
+ return
284
+ image = QImage(str(image_path)).copy()
285
+ if 0 <= page_index < len(self.page_cards) and not image.isNull():
286
+ self.page_cards[page_index].set_image(image)
287
+ self._reflow_grid()
288
+ elif 0 <= page_index < len(self.page_cards):
289
+ self.page_cards[page_index].set_render_error()
290
+ image_path.unlink(missing_ok=True)
291
+ if page_index not in self._render_received:
292
+ self._render_received.add(page_index)
293
+ self._render_pending -= 1
294
+ if self._render_pending == 0:
295
+ self.show_status(
296
+ tr('已載入 {p0} 頁;已選取 {p1} 頁', p0=self.page_count, p1=len(self.selected_pages))
297
+ )
298
+
299
+ def _reset_render_state(self) -> None:
300
+ self._render_received.clear()
301
+
302
+
303
+
304
+ class MainWindow(QMainWindow):
305
+ def __init__(self, render_backend=None) -> None:
306
+ super().__init__()
307
+ self.settings = QSettings("repdf", "repdf")
308
+ migrate_preferences(self.settings, QSettings("PDFPick", "PDFPick"))
309
+ self.language_controller = LanguageController(self.settings.value("ui/language", "system"), self)
310
+ self._theme_controller = ThemeController(QApplication.instance(), self.settings.value("ui/theme", "system"), self)
311
+ self._update_window_title()
312
+ self.setWindowIcon(load_app_icon())
313
+ self.resize(1040, 760)
314
+ self.setMinimumSize(720, 520)
315
+ self.setAcceptDrops(True)
316
+ self.thread_pool = QThreadPool(self)
317
+ self.thread_pool.setMaxThreadCount(4)
318
+ self.preview_temp = tempfile.TemporaryDirectory(prefix="repdf-preview-")
319
+ self.render_service = RenderService(render_backend if render_backend is not None else start_render_backend(), self)
320
+ self.render_service.message.connect(self._handle_render_message, Qt.ConnectionType.QueuedConnection)
321
+ self.documents = {}
322
+ self._next_document_id = 0
323
+ self._next_generation = 0
324
+ self._render_documents = {}
325
+ self._active_tasks = set()
326
+ self._load_batches = {}
327
+ self._document_batches = {}
328
+ self._next_batch = 0
329
+ self._export_running = False
330
+ self._export_id = 0
331
+ self._syncing_bulk = False
332
+ self._closing = False
333
+ self._build_toolbar()
334
+ central = QWidget()
335
+ root = QVBoxLayout(central)
336
+ root.setContentsMargins(12, 10, 12, 12)
337
+ self.empty_label = QLabel(tr('請先開啟 PDF 檔案,或將多個 PDF 拖曳到視窗'))
338
+ self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
339
+ root.addWidget(self.empty_label)
340
+ self.tabs = QTabWidget()
341
+ self.tabs.setDocumentMode(True)
342
+ self.tabs.setMovable(True)
343
+ self.tabs.setElideMode(Qt.TextElideMode.ElideRight)
344
+ self.tabs.tabBar().setExpanding(False)
345
+ self.tabs.tabBar().setDrawBase(False)
346
+ self.tabs.currentChanged.connect(self._tab_changed)
347
+ self.tabs.tabBar().tabMoved.connect(self._sync_controls)
348
+ self.tabs.hide()
349
+ root.addWidget(self.tabs)
350
+ self.setCentralWidget(central)
351
+ self.statusBar().setSizeGripEnabled(False)
352
+ self.statusBar().showMessage(tr('就緒'))
353
+ self.language_controller.changed.connect(self._retranslate)
354
+ self._update_preference_actions()
355
+
356
+ def _build_toolbar(self) -> None:
357
+ self.toolbar = QToolBar(tr('檔案與顯示'), self)
358
+ self.toolbar.setMovable(False)
359
+ self.toolbar.setFloatable(False)
360
+ self.toolbar.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu)
361
+ self.toolbar.setIconSize(QSize(18, 18))
362
+ self.toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
363
+ self.addToolBar(self.toolbar)
364
+
365
+ self.open_action = QAction(tr('開啟 PDF'), self)
366
+ self.open_action.setShortcut(QKeySequence.StandardKey.Open)
367
+ self.open_action.setToolTip(tr('開啟 PDF(Ctrl+O)'))
368
+ self.open_action.triggered.connect(self.choose_pdf)
369
+ self.toolbar.addAction(self.open_action)
370
+ self.export_action = QAction(tr('輸出選取頁面'), self)
371
+ self.export_action.setShortcuts([QKeySequence("Ctrl+S"), QKeySequence("Ctrl+Shift+S")])
372
+ self.export_action.setToolTip(tr('輸出選取頁面為新 PDF(Ctrl+S / Ctrl+Shift+S)'))
373
+ self.export_action.setEnabled(False)
374
+ self.export_action.triggered.connect(self.choose_output)
375
+ self.toolbar.addAction(self.export_action)
376
+ self.split_action = QAction(tr('拆分選取頁面'), self)
377
+ self.split_action.setToolTip(tr('拆分選取頁面:依目前順序存成單頁 PDF,檔名附加補零序號'))
378
+ self.split_action.setEnabled(False)
379
+ self.split_action.triggered.connect(self.choose_split_output)
380
+ self.toolbar.addAction(self.split_action)
381
+ self.toolbar.addSeparator()
382
+
383
+ selection_controls = QWidget()
384
+ bulk = QHBoxLayout(selection_controls)
385
+ bulk.setContentsMargins(8, 0, 4, 0)
386
+ bulk.setSpacing(8)
387
+ self.odd_checkbox = QCheckBox(tr('單數頁'))
388
+ self.even_checkbox = QCheckBox(tr('雙數頁'))
389
+ for checkbox in (self.odd_checkbox, self.even_checkbox):
390
+ checkbox.setTristate(True)
391
+ checkbox.setEnabled(False)
392
+ bulk.addWidget(checkbox)
393
+ self.odd_checkbox.setToolTip(tr('選取或取消原始頁碼的單數頁'))
394
+ self.even_checkbox.setToolTip(tr('選取或取消原始頁碼的雙數頁'))
395
+ self.odd_checkbox.clicked.connect(lambda checked: self._toggle_parity(True, checked))
396
+ self.even_checkbox.clicked.connect(lambda checked: self._toggle_parity(False, checked))
397
+ self.toolbar.addWidget(selection_controls)
398
+
399
+ spacer = QWidget()
400
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
401
+ self.toolbar.addWidget(spacer)
402
+ self.toolbar.addSeparator()
403
+ self.zoom_out_action = QAction(tr('縮小'), self)
404
+ self.zoom_out_action.setShortcut(QKeySequence("Ctrl+-"))
405
+ self.zoom_out_action.setToolTip(tr('縮小(Ctrl+-)'))
406
+ self.zoom_out_action.triggered.connect(lambda: self._adjust_zoom(-25))
407
+ self.addAction(self.zoom_out_action)
408
+ self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
409
+ self.zoom_slider.setRange(25, 200)
410
+ self.zoom_slider.setValue(100)
411
+ self.zoom_slider.setFixedWidth(150)
412
+ self.zoom_slider.setAccessibleName(tr('頁面顯示比例'))
413
+ self.zoom_slider.setToolTip(tr('100% 以 96 DPI 顯示 PDF 頁面'))
414
+ self.zoom_slider.valueChanged.connect(self._zoom_changed)
415
+ self.zoom_in_action = QAction(tr('放大'), self)
416
+ self.zoom_in_action.setShortcuts([QKeySequence("Ctrl++"), QKeySequence("Ctrl+=")])
417
+ self.zoom_in_action.setToolTip(tr('放大(Ctrl++ / Ctrl+=)'))
418
+ self.zoom_in_action.triggered.connect(lambda: self._adjust_zoom(25))
419
+ self.addAction(self.zoom_in_action)
420
+ self.zoom_reset_action = QAction("100%", self)
421
+ self.zoom_reset_action.setShortcut(QKeySequence("Ctrl+0"))
422
+ self.zoom_reset_action.setToolTip(
423
+ tr('頁面顯示比例:25%–200%\n拖曳右側滑桿調整比例。\n放大:Ctrl++ 或 Ctrl+=(每次增加 25 個百分點)\n縮小:Ctrl+-(每次減少 25 個百分點)\n回到 100%:點擊比例或按 Ctrl+0\n100% 以 96 DPI 顯示 PDF 頁面。')
424
+ )
425
+ self.zoom_reset_action.triggered.connect(lambda: self.zoom_slider.setValue(100))
426
+ self.toolbar.addAction(self.zoom_reset_action)
427
+ self.zoom_display = self.toolbar.widgetForAction(self.zoom_reset_action)
428
+ self.zoom_display.setFixedWidth(64)
429
+ self.zoom_display.setToolButtonStyle(
430
+ Qt.ToolButtonStyle.ToolButtonTextOnly
431
+ )
432
+ self.toolbar.addWidget(self.zoom_slider)
433
+ self.toolbar.addSeparator()
434
+ self.language_action = QAction(self)
435
+ self.language_action.triggered.connect(self._cycle_language)
436
+ self.toolbar.addAction(self.language_action)
437
+ self.theme_action = QAction(self)
438
+ self.theme_action.triggered.connect(self._cycle_theme)
439
+ self.toolbar.addAction(self.theme_action)
440
+ self._refresh_toolbar_icons()
441
+
442
+ def _refresh_toolbar_icons(self) -> None:
443
+ for action, kind in ((self.open_action, "open"), (self.export_action, "export"),
444
+ (self.split_action, "split")):
445
+ action.setIcon(toolbar_icon(kind))
446
+ if hasattr(self, "language_action"):
447
+ self._update_preference_actions()
448
+
449
+ def _update_preference_actions(self) -> None:
450
+ language = self.language_controller
451
+ language_names = {"system": tr("跟隨系統"), "zh": tr("中文"), "en": "English"}
452
+ theme_names = {"system": tr("跟隨系統"), "light": tr("淺色"), "dark": tr("深色")}
453
+ self.language_action.setText(tr("介面語言"))
454
+ self.language_action.setIcon(toolbar_icon("language_" + language.mode))
455
+ self.language_action.setToolTip(tr("介面語言:{mode}(目前:{language})\n點擊依序切換:跟隨系統 → 中文 → English",
456
+ mode=language_names[language.mode], language=language_names[language.language]))
457
+ self.theme_action.setText(tr("介面配色"))
458
+ self.theme_action.setIcon(toolbar_icon("theme_" + self._theme_controller.mode))
459
+ self.theme_action.setToolTip(tr("介面配色:{mode}\n點擊依序切換:跟隨系統 → 淺色 → 深色",
460
+ mode=theme_names[self._theme_controller.mode]))
461
+
462
+ def _cycle_language(self) -> None:
463
+ modes = ("system", "zh", "en")
464
+ mode = modes[(modes.index(self.language_controller.mode) + 1) % 3]
465
+ self.settings.setValue("ui/language", mode)
466
+ self.language_controller.set_mode(mode)
467
+
468
+ def _cycle_theme(self) -> None:
469
+ modes = ("system", "light", "dark")
470
+ mode = modes[(modes.index(self._theme_controller.mode) + 1) % 3]
471
+ self.settings.setValue("ui/theme", mode)
472
+ self._theme_controller.set_mode(mode)
473
+ self._update_preference_actions()
474
+
475
+ def _retranslate(self) -> None:
476
+ status = localize_text(self.statusBar().currentMessage())
477
+ for widget in self.findChildren(QLabel) + self.findChildren(QAbstractButton) + self.findChildren(QAction):
478
+ if isinstance(widget, QLabel) and not widget.pixmap().isNull():
479
+ continue
480
+ text = localize_text(widget.text())
481
+ if text != widget.text():
482
+ widget.setText(text)
483
+ for widget in self.findChildren(QWidget) + self.findChildren(QAction):
484
+ widget.setToolTip(localize_text(widget.toolTip()))
485
+ if isinstance(widget, QWidget):
486
+ widget.setAccessibleName(localize_text(widget.accessibleName()))
487
+ self.toolbar.setWindowTitle(localize_text(self.toolbar.windowTitle()))
488
+ doc = self.current_document()
489
+ self._update_window_title(doc.source_path if doc is not None else None)
490
+ self._sync_controls()
491
+ self.statusBar().showMessage(status)
492
+ self._update_preference_actions()
493
+
494
+ def changeEvent(self, event: QEvent) -> None:
495
+ super().changeEvent(event)
496
+ if event.type() == QEvent.Type.PaletteChange and hasattr(self, "zoom_reset_action"):
497
+ self._refresh_toolbar_icons()
498
+ if event.type() == QEvent.Type.LocaleChange and hasattr(self, "language_controller"):
499
+ if self.language_controller.mode == "system":
500
+ self.language_controller.refresh()
501
+
502
+ def _update_window_title(self, path: Path | None = None) -> None:
503
+ title = tr('repdf — PDF 選頁重組工具 {p0} 版', p0=__version__)
504
+ self.setWindowTitle(f"{path.resolve()} — {title}" if path is not None else title)
505
+
506
+ def _adjust_zoom(self, delta: int) -> None:
507
+ self.zoom_slider.setValue(self.zoom_slider.value() + delta)
508
+
509
+ def current_document(self):
510
+ return self.tabs.currentWidget() if hasattr(self, "tabs") else None
511
+
512
+ def ordered_documents(self):
513
+ return [self.tabs.widget(i) for i in range(self.tabs.count())]
514
+
515
+ def output_selection(self) -> list[DocumentSelection]:
516
+ documents = self.ordered_documents()
517
+ included = [doc for doc in documents if doc.include_checkbox.isChecked()]
518
+ if not included:
519
+ current = self.current_document()
520
+ included = [current] if current is not None else []
521
+ return [DocumentSelection(doc.source_path, tuple(page for page in doc.page_order if page in doc.selected_pages))
522
+ for doc in included if doc.ready and doc.selected_pages]
523
+
524
+ def _tab_changed(self, _index: int) -> None:
525
+ if self._closing:
526
+ return
527
+ doc = self.current_document()
528
+ self._update_window_title(doc.source_path if doc is not None else None)
529
+ if doc is not None:
530
+ self.zoom_slider.blockSignals(True)
531
+ self.zoom_slider.setValue(doc.zoom)
532
+ self.zoom_slider.blockSignals(False)
533
+ self.render_service.prioritize(doc.document_id)
534
+ if doc.ready and (doc.render_generation < 0 or doc.render_timer.isActive()):
535
+ doc._queue_all_renders()
536
+ doc._reflow_grid()
537
+ self._sync_controls()
538
+
539
+ def _sync_controls(self, *_args) -> None:
540
+ if not hasattr(self, "tabs") or self._closing:
541
+ return
542
+ doc = self.current_document()
543
+ ready = doc is not None and doc.ready
544
+ self._syncing_bulk = True
545
+ for checkbox, odd in ((self.odd_checkbox, True), (self.even_checkbox, False)):
546
+ checkbox.blockSignals(True)
547
+ state = parity_state(doc.selected_pages, doc.page_count, odd) if ready else SelectionState.NONE
548
+ checkbox.setCheckState({SelectionState.NONE: Qt.CheckState.Unchecked,
549
+ SelectionState.PARTIAL: Qt.CheckState.PartiallyChecked,
550
+ SelectionState.ALL: Qt.CheckState.Checked}[state])
551
+ checkbox.setEnabled(ready and not self._export_running and (odd or doc.page_count >= 2))
552
+ checkbox.blockSignals(False)
553
+ self._syncing_bulk = False
554
+ self.zoom_reset_action.setText(f"{self.zoom_slider.value()}%")
555
+ self.zoom_out_action.setEnabled(self.zoom_slider.value() > 25)
556
+ self.zoom_in_action.setEnabled(self.zoom_slider.value() < 200)
557
+ enabled = bool(self.output_selection()) and not self._export_running
558
+ self.export_action.setEnabled(enabled)
559
+ self.split_action.setEnabled(enabled)
560
+ if not self._export_running:
561
+ count = sum(len(item.pages) for item in self.output_selection())
562
+ self.statusBar().showMessage(
563
+ tr('目前檔案:已選取 {p0} / {p1} 頁;輸出合計 {p2} 頁', p0=len(doc.selected_pages), p1=doc.page_count, p2=count)
564
+ if ready else (tr('正在讀取 PDF…') if doc is not None else tr('就緒')))
565
+
566
+ def _toggle_parity(self, odd: bool, checked: bool) -> None:
567
+ doc = self.current_document()
568
+ if doc is not None and doc.ready and not self._export_running:
569
+ doc._toggle_parity(odd, checked)
570
+
571
+ def _zoom_changed(self, value: int) -> None:
572
+ doc = self.current_document()
573
+ if doc is not None:
574
+ doc.set_zoom(value)
575
+ self._sync_controls()
576
+
577
+ def _dropped_pdfs(self, event) -> list[Path]:
578
+ if self._export_running:
579
+ return []
580
+ urls = event.mimeData().urls()
581
+ if not urls or any(not url.isLocalFile() for url in urls):
582
+ return []
583
+ paths = [Path(url.toLocalFile()) for url in urls]
584
+ return paths if all(path.suffix.lower() == ".pdf" and path.is_file() for path in paths) else []
585
+
586
+ def dragEnterEvent(self, event) -> None:
587
+ if self._dropped_pdfs(event):
588
+ event.acceptProposedAction()
589
+ else:
590
+ event.ignore()
591
+
592
+ def dragMoveEvent(self, event) -> None:
593
+ self.dragEnterEvent(event)
594
+
595
+ def dropEvent(self, event) -> None:
596
+ paths = self._dropped_pdfs(event)
597
+ if not paths:
598
+ event.ignore()
599
+ return
600
+ event.acceptProposedAction()
601
+ self.load_pdfs(paths)
602
+
603
+ @Slot()
604
+ def choose_pdf(self) -> None:
605
+ filenames, _ = QFileDialog.getOpenFileNames(self, tr('開啟 PDF'), str(Path.home()), tr('PDF 檔案 (*.pdf)'), "", QFileDialog.Option.DontUseNativeDialog)
606
+ if filenames:
607
+ self.load_pdfs([Path(filename) for filename in filenames])
608
+
609
+ def _new_document(self, path: Path):
610
+ self._next_document_id += 1
611
+ doc = DocumentView(self, self._next_document_id, path)
612
+ self.documents[doc.document_id] = doc
613
+ self.empty_label.hide()
614
+ self.tabs.show()
615
+ index = self.tabs.addTab(doc, path.stem)
616
+ self.tabs.setTabToolTip(index, str(doc.source_path))
617
+ self.tabs.tabBar().setTabButton(index, QTabBar.ButtonPosition.LeftSide, doc.include_checkbox)
618
+ self.tabs.tabBar().setTabButton(index, QTabBar.ButtonPosition.RightSide, doc.close_button)
619
+ self.tabs.setCurrentIndex(index)
620
+ return doc
621
+
622
+ def load_pdf(self, path: Path) -> None:
623
+ self.load_pdfs([path])
624
+
625
+ def load_pdfs(self, paths: list[Path]) -> None:
626
+ if self._export_running:
627
+ return
628
+ self._next_batch += 1
629
+ batch = self._next_batch
630
+ self._load_batches[batch] = {"pending": set(), "errors": []}
631
+ for path in paths:
632
+ path = Path(path).resolve()
633
+ existing = next((doc for doc in self.documents.values() if doc.source_path == path), None)
634
+ if existing is not None:
635
+ self.tabs.setCurrentWidget(existing)
636
+ continue
637
+ doc = self._new_document(path)
638
+ self._document_batches[doc.document_id] = batch
639
+ self._load_batches[batch]["pending"].add(doc.document_id)
640
+ task = LoadPdfTask(path, doc.document_id)
641
+ self._active_tasks.add(task)
642
+ task.signals.loaded.connect(self._pdf_loaded)
643
+ task.signals.failed.connect(self._pdf_load_failed)
644
+ task.signals.loaded.connect(lambda *_args, job=task: self._active_tasks.discard(job))
645
+ task.signals.failed.connect(lambda *_args, job=task: self._active_tasks.discard(job))
646
+ self.thread_pool.start(task)
647
+ if not self._load_batches[batch]["pending"]:
648
+ self._load_batches.pop(batch)
649
+
650
+ def _finish_load(self, document_id: int, error: str | None = None) -> None:
651
+ batch = self._document_batches.pop(document_id, None)
652
+ if batch is None:
653
+ return
654
+ state = self._load_batches[batch]
655
+ state["pending"].discard(document_id)
656
+ if error:
657
+ state["errors"].append(error)
658
+ if not state["pending"]:
659
+ self._load_batches.pop(batch)
660
+ if state["errors"] and not self._closing:
661
+ QMessageBox.critical(self, tr('部分 PDF 無法開啟'), "\n\n".join(state["errors"]))
662
+
663
+ @Slot(int, object, int)
664
+ def _pdf_loaded(self, document_id: int, path: Path, page_count: int) -> None:
665
+ doc = self.documents.get(document_id)
666
+ if doc is not None and not self._closing:
667
+ doc.loaded(page_count)
668
+ if self.current_document() is doc:
669
+ self._tab_changed(self.tabs.currentIndex())
670
+ self._sync_controls()
671
+ self._finish_load(document_id)
672
+
673
+ @Slot(int, str)
674
+ def _pdf_load_failed(self, document_id: int, message: str) -> None:
675
+ doc = self.documents.get(document_id)
676
+ error = f"{doc.source_path}\n{message}" if doc is not None else None
677
+ if doc is not None:
678
+ self.close_tab(self.tabs.indexOf(doc), force=True)
679
+ self._finish_load(document_id, error)
680
+
681
+ def close_tab(self, index: int, *, force: bool = False) -> None:
682
+ if (self._export_running and not force) or index < 0:
683
+ return
684
+ doc = self.tabs.widget(index)
685
+ if doc is None:
686
+ return
687
+ doc.render_timer.stop()
688
+ doc.grid_host.finish_drag()
689
+ self.render_service.cancel_pending(doc.document_id)
690
+ for generation, document_id in list(self._render_documents.items()):
691
+ if document_id == doc.document_id:
692
+ self._render_documents.pop(generation)
693
+ self.documents.pop(doc.document_id, None)
694
+ self.tabs.removeTab(index)
695
+ doc.deleteLater()
696
+ if not self.tabs.count():
697
+ self.tabs.hide()
698
+ self.empty_label.show()
699
+ self.zoom_slider.blockSignals(True)
700
+ self.zoom_slider.setValue(100)
701
+ self.zoom_slider.blockSignals(False)
702
+ self._sync_controls()
703
+
704
+ @Slot(object)
705
+ def _handle_render_message(self, payload) -> None:
706
+ if not isinstance(payload, dict):
707
+ return
708
+ try:
709
+ generation = int(payload.get("generation", -1))
710
+ except (TypeError, ValueError):
711
+ return
712
+ doc = self.documents.get(self._render_documents.get(generation))
713
+ if doc is not None and doc.render_generation == generation and not self._closing:
714
+ doc._handle_render_message(payload)
715
+ elif payload.get("type") == "page":
716
+ path = Path(str(payload.get("path", "")))
717
+ if path.parent == Path(self.preview_temp.name) and path.name.startswith(f"g{generation}-p"):
718
+ path.unlink(missing_ok=True)
719
+ if payload.get("type") in {"done", "error"}:
720
+ self._render_documents.pop(generation, None)
721
+
722
+ def _set_export_running(self, running: bool) -> None:
723
+ self._export_running = running
724
+ self.open_action.setEnabled(not running)
725
+ self.tabs.setMovable(not running)
726
+ for doc in self.documents.values():
727
+ doc.include_checkbox.setEnabled(doc.ready and not running)
728
+ doc.close_button.setEnabled(not running)
729
+ for card in doc.page_cards:
730
+ card.checkbox.setEnabled(not running)
731
+ self._sync_controls()
732
+
733
+ @Slot()
734
+ def choose_output(self) -> None:
735
+ self._choose_output(split=False)
736
+
737
+ @Slot()
738
+ def choose_split_output(self) -> None:
739
+ self._choose_output(split=True)
740
+
741
+ def _choose_output(self, *, split: bool) -> None:
742
+ selections = self.output_selection()
743
+ if not selections or self._export_running:
744
+ return
745
+ suggested = default_output_path(selections[0].source)
746
+ filename, _ = QFileDialog.getSaveFileName(
747
+ self, tr('拆分選取頁面:指定檔名,將自動附加序號') if split else tr('輸出選取頁面'),
748
+ str(suggested), tr('PDF 檔案 (*.pdf)'), "",
749
+ QFileDialog.Option.DontUseNativeDialog | (QFileDialog.Option.DontConfirmOverwrite if split else QFileDialog.Option(0)),
750
+ )
751
+ if not filename:
752
+ return
753
+ destination = Path(filename)
754
+ if destination.suffix.lower() != ".pdf":
755
+ destination = destination.with_suffix(".pdf")
756
+ outputs = split_output_paths(destination, sum(len(item.pages) for item in selections)) if split else [destination]
757
+ if {doc.source_path for doc in self.documents.values()}.intersection(path.resolve() for path in outputs):
758
+ QMessageBox.warning(self, tr('無法輸出'), tr('輸出檔案不可覆蓋來源 PDF。'))
759
+ return
760
+ overwrite = False
761
+ if split:
762
+ existing = [path for path in outputs if path.exists()]
763
+ if existing:
764
+ answer = QMessageBox.question(
765
+ self, tr('覆蓋單頁檔案'),
766
+ tr('已有 {p0} 個同名單頁檔案,例如:\n{p1}\n\n是否覆蓋?', p0=len(existing), p1=existing[0]),
767
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
768
+ QMessageBox.StandardButton.No,
769
+ )
770
+ if answer != QMessageBox.StandardButton.Yes:
771
+ return
772
+ overwrite = True
773
+ self._start_export(destination, split=split, overwrite=overwrite)
774
+
775
+ def _start_export(self, destination: Path, *, split: bool = False, overwrite: bool = False) -> None:
776
+ selections = self.output_selection()
777
+ if not selections or self._export_running:
778
+ return
779
+ self._export_id += 1
780
+ self._set_export_running(True)
781
+ self._export_split = split
782
+ self.statusBar().showMessage(tr('正在拆分單頁 PDF…') if split else tr('正在輸出 PDF…'))
783
+ task = ExportPdfTask(
784
+ selections,
785
+ destination,
786
+ self._export_id,
787
+ split=split, overwrite=overwrite,
788
+ protected_sources=tuple(doc.source_path for doc in self.documents.values()),
789
+ )
790
+ self._active_tasks.add(task)
791
+ task.signals.completed.connect(self._export_completed)
792
+ task.signals.failed.connect(self._export_failed)
793
+ task.signals.completed.connect(lambda *_args, job=task: self._active_tasks.discard(job))
794
+ task.signals.failed.connect(lambda *_args, job=task: self._active_tasks.discard(job))
795
+ self.thread_pool.start(task)
796
+
797
+ @Slot(int, object, int)
798
+ def _export_completed(self, session: int, destination: Path, count: int) -> None:
799
+ if session != self._export_id or self._closing:
800
+ return
801
+ self._set_export_running(False)
802
+ self.statusBar().showMessage(tr('輸出完成:{p0}', p0=destination))
803
+ if self._export_split:
804
+ outputs = split_output_paths(destination, count)
805
+ message = tr('已拆分為 {p0} 個單頁 PDF:\n{p1}', p0=count, p1=outputs[0])
806
+ if count > 1:
807
+ message += f"\n…\n{outputs[-1]}"
808
+ else:
809
+ message = tr('已輸出 {p0} 頁至:\n{p1}', p0=count, p1=destination)
810
+ QMessageBox.information(self, tr('輸出完成'), message)
811
+
812
+ @Slot(int, str)
813
+ def _export_failed(self, session: int, message: str) -> None:
814
+ if session != self._export_id or self._closing:
815
+ return
816
+ self._set_export_running(False)
817
+ self.statusBar().showMessage(tr('輸出失敗'))
818
+ QMessageBox.critical(self, tr('輸出失敗'), localize_text(message))
819
+
820
+ def closeEvent(self, event: QCloseEvent) -> None:
821
+ self._closing = True
822
+ for doc in self.documents.values():
823
+ doc.render_timer.stop()
824
+ doc.grid_host.finish_drag()
825
+ self.render_service.close()
826
+ self.thread_pool.clear()
827
+ self.thread_pool.waitForDone()
828
+ self.preview_temp.cleanup()
829
+ self.language_controller.close()
830
+ event.accept()
831
+
832
+
833
+ def load_app_icon() -> QIcon:
834
+ return QIcon(str(files("repdf").joinpath("assets", "repdf.png")))
835
+
836
+
837
+ def configure_application(app: QApplication) -> None:
838
+ app.setApplicationName("repdf")
839
+ app.setOrganizationName("repdf")
840
+ app.setStyle("Fusion")
841
+ app.setWindowIcon(load_app_icon())
842
+ available = set(QFontDatabase.families())
843
+ for family in (
844
+ "Microsoft JhengHei UI",
845
+ "Microsoft JhengHei",
846
+ "Noto Sans TC",
847
+ "Arial",
848
+ ):
849
+ if family in available:
850
+ app.setFont(QFont(family, 10))
851
+ break
852
+
853
+
854
+ def main() -> int:
855
+ render_backend = start_render_backend()
856
+ app = QApplication(sys.argv)
857
+ configure_application(app)
858
+ window = MainWindow(render_backend)
859
+ window.show()
860
+ return app.exec()