Files
nl-tools/tools/pdf/merge.py
2025-12-11 09:47:00 +08:00

388 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
PDF合并工具
- 文件列表 + 拖拽排序
- 添加/删除/上下移动
- 一键合并
"""
import os
import logging
from pathlib import Path
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QFileDialog, QMessageBox, QProgressBar,
QListWidget, QListWidgetItem, QAbstractItemView
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QFont, QIcon
from ui.workspace import BaseWorkspace, UploadArea
try:
import fitz
HAS_PYMUPDF = True
except ImportError:
HAS_PYMUPDF = False
class MergeWorker(QThread):
"""合并工作线程"""
progress = Signal(int, int)
finished = Signal(str)
error = Signal(str)
def __init__(self, files: list, output_path: str):
super().__init__()
self.files = files
self.output_path = output_path
def run(self):
try:
merged = fitz.open()
total = len(self.files)
for i, file_path in enumerate(self.files):
doc = fitz.open(file_path)
merged.insert_pdf(doc)
doc.close()
self.progress.emit(i + 1, total)
merged.save(self.output_path)
merged.close()
self.finished.emit(self.output_path)
except Exception as e:
logging.error(f"合并PDF失败: {e}")
self.error.emit(str(e))
class PDFFileItem(QWidget):
"""PDF文件列表项"""
remove_clicked = Signal(str) # file_path
def __init__(self, file_path: str, parent=None):
super().__init__(parent)
self.file_path = file_path
self.setup_ui()
def setup_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(12)
# 拖拽手柄
handle = QLabel("⋮⋮")
handle.setStyleSheet("color: #64748b; font-size: 16px;")
handle.setCursor(Qt.CursorShape.OpenHandCursor)
layout.addWidget(handle)
# PDF图标
icon = QLabel("📄")
icon.setFont(QFont("Segoe UI Emoji", 16))
layout.addWidget(icon)
# 文件信息
info_layout = QVBoxLayout()
info_layout.setSpacing(2)
name = QLabel(Path(self.file_path).name)
name.setStyleSheet("color: #e2e8f0; font-size: 13px; font-weight: 500;")
info_layout.addWidget(name)
# 文件大小
size = os.path.getsize(self.file_path)
size_str = self.format_size(size)
size_label = QLabel(size_str)
size_label.setStyleSheet("color: #64748b; font-size: 11px;")
info_layout.addWidget(size_label)
layout.addLayout(info_layout, 1)
# 删除按钮
remove_btn = QPushButton("🗑")
remove_btn.setFixedSize(32, 32)
remove_btn.setStyleSheet("""
QPushButton {
background: transparent;
border: none;
border-radius: 4px;
}
QPushButton:hover {
background: rgba(239, 68, 68, 0.2);
}
""")
remove_btn.clicked.connect(lambda: self.remove_clicked.emit(self.file_path))
layout.addWidget(remove_btn)
@staticmethod
def format_size(size: int) -> str:
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
class PDFMergePage(BaseWorkspace):
"""PDF合并页面"""
def __init__(self, parent=None):
super().__init__(parent)
self.files = []
self.setup_merge_ui()
def setup_merge_ui(self):
"""设置合并UI"""
self.history_btn.hide()
# 上传区域
self.upload_area = UploadArea("PDF文件 (*.pdf)")
self.upload_area.files_dropped.connect(self.on_files_added)
self.content_layout.addWidget(self.upload_area)
# 文件列表区域
list_frame = QFrame()
list_frame.setObjectName("card")
list_layout = QVBoxLayout(list_frame)
list_layout.setContentsMargins(20, 20, 20, 20)
list_layout.setSpacing(16)
# 标题
header = QHBoxLayout()
title = QLabel("📑 待合并文件列表")
title.setStyleSheet("color: white; font-weight: 600; font-size: 16px;")
header.addWidget(title)
header.addStretch()
hint = QLabel("(可拖拽排序)")
hint.setStyleSheet("color: #64748b; font-size: 12px;")
header.addWidget(hint)
list_layout.addLayout(header)
# 文件列表
self.file_list = QListWidget()
self.file_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
self.file_list.setDefaultDropAction(Qt.DropAction.MoveAction)
self.file_list.setMinimumHeight(250)
self.file_list.setStyleSheet("""
QListWidget {
background: rgba(15, 23, 42, 0.5);
border: 1px solid #334155;
border-radius: 8px;
}
QListWidget::item {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
margin: 4px;
padding: 4px;
}
QListWidget::item:hover {
border-color: #fbbf24;
}
QListWidget::item:selected {
background: rgba(251, 191, 36, 0.1);
border-color: #fbbf24;
}
""")
self.file_list.model().rowsMoved.connect(self.on_rows_moved)
list_layout.addWidget(self.file_list)
# 操作按钮
btn_layout = QHBoxLayout()
add_btn = QPushButton(" 添加文件")
add_btn.setObjectName("secondary_btn")
add_btn.clicked.connect(self.add_files)
btn_layout.addWidget(add_btn)
move_up_btn = QPushButton("⬆️ 上移")
move_up_btn.setObjectName("secondary_btn")
move_up_btn.clicked.connect(self.move_up)
btn_layout.addWidget(move_up_btn)
move_down_btn = QPushButton("⬇️ 下移")
move_down_btn.setObjectName("secondary_btn")
move_down_btn.clicked.connect(self.move_down)
btn_layout.addWidget(move_down_btn)
btn_layout.addStretch()
clear_btn = QPushButton("🗑️ 清空列表")
clear_btn.setObjectName("secondary_btn")
clear_btn.clicked.connect(self.clear_files)
btn_layout.addWidget(clear_btn)
list_layout.addLayout(btn_layout)
# 进度条
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
list_layout.addWidget(self.progress_bar)
# 合并按钮
self.merge_btn = QPushButton("📑 合并为单个 PDF")
self.merge_btn.setObjectName("primary_btn")
self.merge_btn.setMinimumHeight(50)
self.merge_btn.setFont(QFont("Microsoft YaHei", 13, QFont.Weight.Bold))
self.merge_btn.clicked.connect(self.do_merge)
list_layout.addWidget(self.merge_btn)
self.content_layout.addWidget(list_frame, 1)
def on_files_added(self, files: list):
"""文件添加"""
for file_path in files:
if file_path.lower().endswith('.pdf') and file_path not in self.files:
self.files.append(file_path)
self.add_file_item(file_path)
logging.info(f"添加了 {len(files)} 个PDF文件")
def add_file_item(self, file_path: str):
"""添加文件列表项"""
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, file_path)
item.setSizeHint(QListWidgetItem().sizeHint())
item.setSizeHint(item.sizeHint().expandedTo(QListWidgetItem().sizeHint()))
widget = PDFFileItem(file_path)
widget.remove_clicked.connect(self.remove_file)
item.setSizeHint(widget.sizeHint())
self.file_list.addItem(item)
self.file_list.setItemWidget(item, widget)
def remove_file(self, file_path: str):
"""移除文件"""
if file_path in self.files:
self.files.remove(file_path)
for i in range(self.file_list.count()):
item = self.file_list.item(i)
if item.data(Qt.ItemDataRole.UserRole) == file_path:
self.file_list.takeItem(i)
break
def add_files(self):
"""添加文件对话框"""
files, _ = QFileDialog.getOpenFileNames(
self, "选择PDF文件", "", "PDF文件 (*.pdf)"
)
if files:
self.on_files_added(files)
def move_up(self):
"""上移"""
row = self.file_list.currentRow()
if row > 0:
item = self.file_list.takeItem(row)
self.file_list.insertItem(row - 1, item)
self.file_list.setCurrentRow(row - 1)
# 重新创建widget
file_path = item.data(Qt.ItemDataRole.UserRole)
widget = PDFFileItem(file_path)
widget.remove_clicked.connect(self.remove_file)
item.setSizeHint(widget.sizeHint())
self.file_list.setItemWidget(item, widget)
self.sync_files_order()
def move_down(self):
"""下移"""
row = self.file_list.currentRow()
if row < self.file_list.count() - 1:
item = self.file_list.takeItem(row)
self.file_list.insertItem(row + 1, item)
self.file_list.setCurrentRow(row + 1)
# 重新创建widget
file_path = item.data(Qt.ItemDataRole.UserRole)
widget = PDFFileItem(file_path)
widget.remove_clicked.connect(self.remove_file)
item.setSizeHint(widget.sizeHint())
self.file_list.setItemWidget(item, widget)
self.sync_files_order()
def on_rows_moved(self):
"""行移动后同步文件顺序"""
self.sync_files_order()
def sync_files_order(self):
"""同步文件顺序"""
self.files = []
for i in range(self.file_list.count()):
item = self.file_list.item(i)
file_path = item.data(Qt.ItemDataRole.UserRole)
if file_path:
self.files.append(file_path)
def clear_files(self):
"""清空文件"""
self.files.clear()
self.file_list.clear()
def do_merge(self):
"""执行合并"""
if not HAS_PYMUPDF:
QMessageBox.critical(self, "错误", "PyMuPDF未安装,无法合并PDF")
return
if len(self.files) < 2:
QMessageBox.warning(self, "提示", "请至少添加2个PDF文件")
return
# 同步顺序
self.sync_files_order()
# 选择保存路径
save_path, _ = QFileDialog.getSaveFileName(
self, "保存合并后的PDF", "merged.pdf", "PDF文件 (*.pdf)"
)
if not save_path:
return
# 开始合并
self.merge_btn.setEnabled(False)
self.progress_bar.setVisible(True)
self.progress_bar.setValue(0)
self.worker = MergeWorker(self.files, save_path)
self.worker.progress.connect(self.on_progress)
self.worker.finished.connect(self.on_merge_finished)
self.worker.error.connect(self.on_merge_error)
self.worker.start()
logging.info(f"开始合并 {len(self.files)} 个PDF文件")
def on_progress(self, current: int, total: int):
"""进度更新"""
self.progress_bar.setValue(int(current / total * 100))
def on_merge_finished(self, output_path: str):
"""合并完成"""
self.merge_btn.setEnabled(True)
self.progress_bar.setVisible(False)
QMessageBox.information(
self, "成功",
f"PDF合并完成!\n\n共合并 {len(self.files)} 个文件\n保存到: {output_path}"
)
logging.info(f"PDF合并完成: {output_path}")
def on_merge_error(self, error: str):
"""合并错误"""
self.merge_btn.setEnabled(True)
self.progress_bar.setVisible(False)
QMessageBox.critical(self, "错误", f"合并失败:\n{error}")
logging.error(f"PDF合并失败: {error}")