初始化
This commit is contained in:
2
tools/pdf/__init__.py
Normal file
2
tools/pdf/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# PDF tools module
|
||||
|
||||
387
tools/pdf/merge.py
Normal file
387
tools/pdf/merge.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
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}")
|
||||
|
||||
400
tools/pdf/split.py
Normal file
400
tools/pdf/split.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
PDF拆分工具
|
||||
- 渲染PDF页面缩略图网格
|
||||
- 多选页面(复选框)
|
||||
- 导出选中页面为新PDF
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QFrame, QFileDialog, QMessageBox, QProgressBar,
|
||||
QScrollArea, QGridLayout, QCheckBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QSize
|
||||
from PySide6.QtGui import QFont, QPixmap, QImage
|
||||
|
||||
from ui.workspace import BaseWorkspace, UploadArea
|
||||
|
||||
# PDF处理
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
HAS_PYMUPDF = True
|
||||
except ImportError:
|
||||
HAS_PYMUPDF = False
|
||||
logging.warning("PyMuPDF未安装, PDF功能不可用")
|
||||
|
||||
|
||||
class PDFRenderWorker(QThread):
|
||||
"""PDF页面渲染线程"""
|
||||
page_rendered = Signal(int, QPixmap) # page_num, pixmap
|
||||
finished = Signal(int) # total_pages
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, pdf_path: str, dpi: int = 72):
|
||||
super().__init__()
|
||||
self.pdf_path = pdf_path
|
||||
self.dpi = dpi
|
||||
|
||||
def run(self):
|
||||
if not HAS_PYMUPDF:
|
||||
self.error.emit("PyMuPDF未安装")
|
||||
return
|
||||
|
||||
try:
|
||||
doc = fitz.open(self.pdf_path)
|
||||
total_pages = len(doc)
|
||||
|
||||
for page_num in range(total_pages):
|
||||
page = doc[page_num]
|
||||
# 渲染页面
|
||||
mat = fitz.Matrix(self.dpi / 72, self.dpi / 72)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
|
||||
# 转换为 QPixmap
|
||||
img = QImage(
|
||||
pix.samples,
|
||||
pix.width,
|
||||
pix.height,
|
||||
pix.stride,
|
||||
QImage.Format.Format_RGB888 if pix.n == 3 else QImage.Format.Format_RGBA8888
|
||||
)
|
||||
pixmap = QPixmap.fromImage(img)
|
||||
|
||||
self.page_rendered.emit(page_num, pixmap)
|
||||
|
||||
doc.close()
|
||||
self.finished.emit(total_pages)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"渲染PDF失败: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class PageThumbnail(QFrame):
|
||||
"""页面缩略图组件"""
|
||||
|
||||
selection_changed = Signal(int, bool) # page_num, selected
|
||||
|
||||
def __init__(self, page_num: int, parent=None):
|
||||
super().__init__(parent)
|
||||
self.page_num = page_num
|
||||
self.setObjectName("page_thumbnail")
|
||||
self.setFixedSize(140, 200)
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
self.setStyleSheet("""
|
||||
#page_thumbnail {
|
||||
background: white;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
#page_thumbnail:hover {
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(4)
|
||||
|
||||
# 预览图
|
||||
self.preview_label = QLabel()
|
||||
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.preview_label.setStyleSheet("background: #f1f5f9; border-radius: 4px;")
|
||||
self.preview_label.setMinimumHeight(150)
|
||||
layout.addWidget(self.preview_label, 1)
|
||||
|
||||
# 底部信息
|
||||
bottom = QHBoxLayout()
|
||||
|
||||
self.checkbox = QCheckBox()
|
||||
self.checkbox.stateChanged.connect(self.on_checkbox_changed)
|
||||
bottom.addWidget(self.checkbox)
|
||||
|
||||
page_label = QLabel(f"第 {self.page_num + 1} 页")
|
||||
page_label.setStyleSheet("color: #1e293b; font-size: 11px;")
|
||||
bottom.addWidget(page_label)
|
||||
bottom.addStretch()
|
||||
|
||||
layout.addLayout(bottom)
|
||||
|
||||
def set_pixmap(self, pixmap: QPixmap):
|
||||
"""设置预览图"""
|
||||
scaled = pixmap.scaled(
|
||||
QSize(130, 140),
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.preview_label.setPixmap(scaled)
|
||||
|
||||
def on_checkbox_changed(self, state):
|
||||
"""复选框状态变化"""
|
||||
selected = state == Qt.CheckState.Checked.value
|
||||
self.selection_changed.emit(self.page_num, selected)
|
||||
|
||||
# 更新样式
|
||||
if selected:
|
||||
self.setStyleSheet("""
|
||||
#page_thumbnail {
|
||||
background: white;
|
||||
border: 2px solid #fbbf24;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
""")
|
||||
else:
|
||||
self.setStyleSheet("""
|
||||
#page_thumbnail {
|
||||
background: white;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
#page_thumbnail:hover {
|
||||
border-color: #fbbf24;
|
||||
}
|
||||
""")
|
||||
|
||||
def set_selected(self, selected: bool):
|
||||
"""设置选中状态"""
|
||||
self.checkbox.setChecked(selected)
|
||||
|
||||
def is_selected(self) -> bool:
|
||||
"""是否选中"""
|
||||
return self.checkbox.isChecked()
|
||||
|
||||
|
||||
class PDFSplitPage(BaseWorkspace):
|
||||
"""PDF拆分页面"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.pdf_path = None
|
||||
self.total_pages = 0
|
||||
self.page_thumbnails = []
|
||||
self.selected_pages = set()
|
||||
self.setup_split_ui()
|
||||
|
||||
def setup_split_ui(self):
|
||||
"""设置拆分UI"""
|
||||
self.history_btn.hide()
|
||||
|
||||
# 上传区域
|
||||
self.upload_area = UploadArea("PDF文件 (*.pdf)")
|
||||
self.upload_area.files_dropped.connect(self.on_file_added)
|
||||
self.content_layout.addWidget(self.upload_area)
|
||||
|
||||
# 页面选择区域
|
||||
self.pages_frame = QFrame()
|
||||
self.pages_frame.setObjectName("card")
|
||||
self.pages_frame.setVisible(False)
|
||||
pages_layout = QVBoxLayout(self.pages_frame)
|
||||
pages_layout.setContentsMargins(20, 20, 20, 20)
|
||||
pages_layout.setSpacing(16)
|
||||
|
||||
# 标题栏
|
||||
header = QHBoxLayout()
|
||||
|
||||
title = QLabel("📄 选择页面")
|
||||
title.setStyleSheet("color: white; font-weight: 600; font-size: 16px;")
|
||||
header.addWidget(title)
|
||||
|
||||
header.addStretch()
|
||||
|
||||
# 全选/清空
|
||||
select_all_btn = QPushButton("全选")
|
||||
select_all_btn.setObjectName("secondary_btn")
|
||||
select_all_btn.clicked.connect(self.select_all)
|
||||
header.addWidget(select_all_btn)
|
||||
|
||||
clear_btn = QPushButton("清空")
|
||||
clear_btn.setObjectName("secondary_btn")
|
||||
clear_btn.clicked.connect(self.clear_selection)
|
||||
header.addWidget(clear_btn)
|
||||
|
||||
pages_layout.addLayout(header)
|
||||
|
||||
# 文件信息
|
||||
self.file_info = QLabel("")
|
||||
self.file_info.setStyleSheet("color: #94a3b8; font-size: 12px;")
|
||||
pages_layout.addWidget(self.file_info)
|
||||
|
||||
# 页面网格(滚动区域)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setStyleSheet("background: rgba(15, 23, 42, 0.3); border-radius: 8px;")
|
||||
scroll.setMinimumHeight(350)
|
||||
|
||||
self.grid_container = QWidget()
|
||||
self.grid_layout = QGridLayout(self.grid_container)
|
||||
self.grid_layout.setSpacing(16)
|
||||
self.grid_layout.setContentsMargins(16, 16, 16, 16)
|
||||
|
||||
scroll.setWidget(self.grid_container)
|
||||
pages_layout.addWidget(scroll, 1)
|
||||
|
||||
# 进度条
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
pages_layout.addWidget(self.progress_bar)
|
||||
|
||||
# 底部操作
|
||||
bottom = QHBoxLayout()
|
||||
|
||||
self.selection_label = QLabel("已选择: 0 页")
|
||||
self.selection_label.setStyleSheet("color: #fbbf24; font-size: 13px;")
|
||||
bottom.addWidget(self.selection_label)
|
||||
|
||||
bottom.addStretch()
|
||||
|
||||
self.split_btn = QPushButton("✂️ 拆分选定页面")
|
||||
self.split_btn.setObjectName("primary_btn")
|
||||
self.split_btn.setMinimumSize(150, 40)
|
||||
self.split_btn.setFont(QFont("Microsoft YaHei", 11, QFont.Weight.Bold))
|
||||
self.split_btn.clicked.connect(self.do_split)
|
||||
bottom.addWidget(self.split_btn)
|
||||
|
||||
pages_layout.addLayout(bottom)
|
||||
|
||||
self.content_layout.addWidget(self.pages_frame, 1)
|
||||
|
||||
def on_file_added(self, files: list):
|
||||
"""PDF文件添加"""
|
||||
if not files:
|
||||
return
|
||||
|
||||
pdf_file = None
|
||||
for f in files:
|
||||
if f.lower().endswith('.pdf'):
|
||||
pdf_file = f
|
||||
break
|
||||
|
||||
if not pdf_file:
|
||||
QMessageBox.warning(self, "提示", "请选择PDF文件")
|
||||
return
|
||||
|
||||
self.pdf_path = pdf_file
|
||||
self.load_pdf()
|
||||
|
||||
def load_pdf(self):
|
||||
"""加载PDF"""
|
||||
if not HAS_PYMUPDF:
|
||||
QMessageBox.critical(self, "错误", "PyMuPDF未安装,无法处理PDF文件")
|
||||
return
|
||||
|
||||
# 清空现有内容
|
||||
self.clear_pages()
|
||||
|
||||
# 显示页面区域
|
||||
self.pages_frame.setVisible(True)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
|
||||
self.file_info.setText(f"📁 {Path(self.pdf_path).name}")
|
||||
|
||||
# 启动渲染线程
|
||||
self.render_worker = PDFRenderWorker(self.pdf_path)
|
||||
self.render_worker.page_rendered.connect(self.on_page_rendered)
|
||||
self.render_worker.finished.connect(self.on_render_finished)
|
||||
self.render_worker.error.connect(self.on_render_error)
|
||||
self.render_worker.start()
|
||||
|
||||
logging.info(f"开始加载PDF: {self.pdf_path}")
|
||||
|
||||
def on_page_rendered(self, page_num: int, pixmap: QPixmap):
|
||||
"""页面渲染完成"""
|
||||
thumbnail = PageThumbnail(page_num)
|
||||
thumbnail.set_pixmap(pixmap)
|
||||
thumbnail.selection_changed.connect(self.on_page_selection_changed)
|
||||
|
||||
# 添加到网格
|
||||
row = page_num // 5
|
||||
col = page_num % 5
|
||||
self.grid_layout.addWidget(thumbnail, row, col)
|
||||
self.page_thumbnails.append(thumbnail)
|
||||
|
||||
# 更新进度
|
||||
if self.total_pages > 0:
|
||||
self.progress_bar.setValue(int((page_num + 1) / self.total_pages * 100))
|
||||
|
||||
def on_render_finished(self, total_pages: int):
|
||||
"""渲染完成"""
|
||||
self.total_pages = total_pages
|
||||
self.progress_bar.setVisible(False)
|
||||
self.file_info.setText(f"📁 {Path(self.pdf_path).name} | 共 {total_pages} 页")
|
||||
logging.info(f"PDF加载完成: {total_pages} 页")
|
||||
|
||||
def on_render_error(self, error: str):
|
||||
"""渲染错误"""
|
||||
self.progress_bar.setVisible(False)
|
||||
QMessageBox.critical(self, "错误", f"加载PDF失败:\n{error}")
|
||||
logging.error(f"加载PDF失败: {error}")
|
||||
|
||||
def on_page_selection_changed(self, page_num: int, selected: bool):
|
||||
"""页面选择变化"""
|
||||
if selected:
|
||||
self.selected_pages.add(page_num)
|
||||
else:
|
||||
self.selected_pages.discard(page_num)
|
||||
|
||||
self.selection_label.setText(f"已选择: {len(self.selected_pages)} 页")
|
||||
|
||||
def select_all(self):
|
||||
"""全选"""
|
||||
for thumb in self.page_thumbnails:
|
||||
thumb.set_selected(True)
|
||||
|
||||
def clear_selection(self):
|
||||
"""清空选择"""
|
||||
for thumb in self.page_thumbnails:
|
||||
thumb.set_selected(False)
|
||||
|
||||
def clear_pages(self):
|
||||
"""清空页面"""
|
||||
for thumb in self.page_thumbnails:
|
||||
thumb.deleteLater()
|
||||
self.page_thumbnails.clear()
|
||||
self.selected_pages.clear()
|
||||
self.selection_label.setText("已选择: 0 页")
|
||||
|
||||
def do_split(self):
|
||||
"""执行拆分"""
|
||||
if not self.selected_pages:
|
||||
QMessageBox.warning(self, "提示", "请先选择要提取的页面")
|
||||
return
|
||||
|
||||
# 选择保存路径
|
||||
save_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "保存拆分后的PDF",
|
||||
f"{Path(self.pdf_path).stem}_split.pdf",
|
||||
"PDF文件 (*.pdf)"
|
||||
)
|
||||
|
||||
if not save_path:
|
||||
return
|
||||
|
||||
try:
|
||||
doc = fitz.open(self.pdf_path)
|
||||
new_doc = fitz.open()
|
||||
|
||||
# 按页码顺序添加
|
||||
for page_num in sorted(self.selected_pages):
|
||||
new_doc.insert_pdf(doc, from_page=page_num, to_page=page_num)
|
||||
|
||||
new_doc.save(save_path)
|
||||
new_doc.close()
|
||||
doc.close()
|
||||
|
||||
QMessageBox.information(
|
||||
self, "成功",
|
||||
f"已成功提取 {len(self.selected_pages)} 页!\n\n保存到: {save_path}"
|
||||
)
|
||||
logging.info(f"PDF拆分完成: {len(self.selected_pages)} 页 -> {save_path}")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "错误", f"拆分失败:\n{e}")
|
||||
logging.error(f"PDF拆分失败: {e}")
|
||||
|
||||
246
tools/pdf/to_word.py
Normal file
246
tools/pdf/to_word.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
PDF转Word工具
|
||||
- 单文件上传
|
||||
- 进度条显示转换进度
|
||||
- 保持原始排版
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QFrame, QFileDialog, QMessageBox, QProgressBar
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QFont, QPixmap
|
||||
|
||||
from ui.workspace import BaseWorkspace, UploadArea
|
||||
|
||||
try:
|
||||
from pdf2docx import Converter
|
||||
HAS_PDF2DOCX = True
|
||||
except ImportError:
|
||||
HAS_PDF2DOCX = False
|
||||
logging.warning("pdf2docx未安装, PDF转Word功能不可用")
|
||||
|
||||
|
||||
class ConvertWorker(QThread):
|
||||
"""转换工作线程"""
|
||||
progress = Signal(int) # 百分比
|
||||
finished = Signal(str) # 输出路径
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, pdf_path: str, output_path: str):
|
||||
super().__init__()
|
||||
self.pdf_path = pdf_path
|
||||
self.output_path = output_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
cv = Converter(self.pdf_path)
|
||||
|
||||
# pdf2docx 没有直接的进度回调,我们模拟进度
|
||||
self.progress.emit(10)
|
||||
|
||||
cv.convert(self.output_path)
|
||||
self.progress.emit(90)
|
||||
|
||||
cv.close()
|
||||
self.progress.emit(100)
|
||||
|
||||
self.finished.emit(self.output_path)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"PDF转Word失败: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class PDFToWordPage(BaseWorkspace):
|
||||
"""PDF转Word页面"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.pdf_path = None
|
||||
self.setup_convert_ui()
|
||||
|
||||
def setup_convert_ui(self):
|
||||
"""设置转换UI"""
|
||||
self.history_btn.hide()
|
||||
|
||||
# 上传区域
|
||||
self.upload_area = UploadArea("PDF文件 (*.pdf)")
|
||||
self.upload_area.files_dropped.connect(self.on_file_added)
|
||||
self.content_layout.addWidget(self.upload_area)
|
||||
|
||||
# 转换区域
|
||||
convert_frame = QFrame()
|
||||
convert_frame.setObjectName("card")
|
||||
convert_layout = QVBoxLayout(convert_frame)
|
||||
convert_layout.setContentsMargins(32, 32, 32, 32)
|
||||
convert_layout.setSpacing(24)
|
||||
convert_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# 图标
|
||||
icon_layout = QHBoxLayout()
|
||||
icon_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
icon_layout.setSpacing(24)
|
||||
|
||||
pdf_icon = QLabel("📄")
|
||||
pdf_icon.setFont(QFont("Segoe UI Emoji", 48))
|
||||
pdf_icon.setStyleSheet("background: rgba(239, 68, 68, 0.1); border-radius: 16px; padding: 16px;")
|
||||
icon_layout.addWidget(pdf_icon)
|
||||
|
||||
arrow = QLabel("➡️")
|
||||
arrow.setFont(QFont("Segoe UI Emoji", 32))
|
||||
icon_layout.addWidget(arrow)
|
||||
|
||||
word_icon = QLabel("📝")
|
||||
word_icon.setFont(QFont("Segoe UI Emoji", 48))
|
||||
word_icon.setStyleSheet("background: rgba(59, 130, 246, 0.1); border-radius: 16px; padding: 16px;")
|
||||
icon_layout.addWidget(word_icon)
|
||||
|
||||
convert_layout.addLayout(icon_layout)
|
||||
|
||||
# 文件信息
|
||||
self.file_info = QLabel("选择PDF文件开始转换")
|
||||
self.file_info.setStyleSheet("color: #94a3b8; font-size: 14px;")
|
||||
self.file_info.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
convert_layout.addWidget(self.file_info)
|
||||
|
||||
# 特性说明
|
||||
features_layout = QHBoxLayout()
|
||||
features_layout.setSpacing(32)
|
||||
features_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
features = [
|
||||
("✅", "保持排版"),
|
||||
("✅", "保留图片"),
|
||||
("✅", "提取表格")
|
||||
]
|
||||
|
||||
for icon, text in features:
|
||||
feature = QLabel(f"{icon} {text}")
|
||||
feature.setStyleSheet("color: #22c55e; font-size: 13px;")
|
||||
features_layout.addWidget(feature)
|
||||
|
||||
convert_layout.addLayout(features_layout)
|
||||
|
||||
# 进度条
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
self.progress_bar.setMinimumWidth(400)
|
||||
convert_layout.addWidget(self.progress_bar, 0, Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# 状态标签
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #fbbf24; font-size: 13px;")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setVisible(False)
|
||||
convert_layout.addWidget(self.status_label)
|
||||
|
||||
# 转换按钮
|
||||
self.convert_btn = QPushButton("📝 开始转换")
|
||||
self.convert_btn.setObjectName("primary_btn")
|
||||
self.convert_btn.setMinimumSize(200, 50)
|
||||
self.convert_btn.setFont(QFont("Microsoft YaHei", 13, QFont.Weight.Bold))
|
||||
self.convert_btn.clicked.connect(self.do_convert)
|
||||
self.convert_btn.setEnabled(False)
|
||||
convert_layout.addWidget(self.convert_btn, 0, Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# 提示
|
||||
hint = QLabel("提示: 转换复杂PDF可能需要较长时间,请耐心等待")
|
||||
hint.setStyleSheet("color: #64748b; font-size: 11px;")
|
||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
convert_layout.addWidget(hint)
|
||||
|
||||
self.content_layout.addWidget(convert_frame)
|
||||
self.content_layout.addStretch()
|
||||
|
||||
def on_file_added(self, files: list):
|
||||
"""文件添加"""
|
||||
if not files:
|
||||
return
|
||||
|
||||
pdf_file = None
|
||||
for f in files:
|
||||
if f.lower().endswith('.pdf'):
|
||||
pdf_file = f
|
||||
break
|
||||
|
||||
if not pdf_file:
|
||||
QMessageBox.warning(self, "提示", "请选择PDF文件")
|
||||
return
|
||||
|
||||
self.pdf_path = pdf_file
|
||||
self.file_info.setText(f"📁 {Path(pdf_file).name}")
|
||||
self.file_info.setStyleSheet("color: white; font-size: 14px; font-weight: 500;")
|
||||
self.convert_btn.setEnabled(True)
|
||||
|
||||
logging.info(f"已选择PDF文件: {pdf_file}")
|
||||
|
||||
def do_convert(self):
|
||||
"""执行转换"""
|
||||
if not HAS_PDF2DOCX:
|
||||
QMessageBox.critical(self, "错误", "pdf2docx未安装,无法转换PDF")
|
||||
return
|
||||
|
||||
if not self.pdf_path:
|
||||
QMessageBox.warning(self, "提示", "请先选择PDF文件")
|
||||
return
|
||||
|
||||
# 选择保存路径
|
||||
default_name = Path(self.pdf_path).stem + ".docx"
|
||||
save_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "保存Word文档", default_name, "Word文档 (*.docx)"
|
||||
)
|
||||
|
||||
if not save_path:
|
||||
return
|
||||
|
||||
# 开始转换
|
||||
self.convert_btn.setEnabled(False)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
self.status_label.setVisible(True)
|
||||
self.status_label.setText("正在转换中,请稍候...")
|
||||
|
||||
self.worker = ConvertWorker(self.pdf_path, save_path)
|
||||
self.worker.progress.connect(self.on_progress)
|
||||
self.worker.finished.connect(self.on_convert_finished)
|
||||
self.worker.error.connect(self.on_convert_error)
|
||||
self.worker.start()
|
||||
|
||||
logging.info(f"开始转换PDF: {self.pdf_path}")
|
||||
|
||||
def on_progress(self, value: int):
|
||||
"""进度更新"""
|
||||
self.progress_bar.setValue(value)
|
||||
|
||||
if value < 30:
|
||||
self.status_label.setText("正在解析PDF结构...")
|
||||
elif value < 70:
|
||||
self.status_label.setText("正在转换内容...")
|
||||
else:
|
||||
self.status_label.setText("正在生成Word文档...")
|
||||
|
||||
def on_convert_finished(self, output_path: str):
|
||||
"""转换完成"""
|
||||
self.convert_btn.setEnabled(True)
|
||||
self.progress_bar.setVisible(False)
|
||||
self.status_label.setVisible(False)
|
||||
|
||||
QMessageBox.information(
|
||||
self, "成功",
|
||||
f"PDF转Word完成!\n\n保存到: {output_path}"
|
||||
)
|
||||
logging.info(f"PDF转Word完成: {output_path}")
|
||||
|
||||
def on_convert_error(self, error: str):
|
||||
"""转换错误"""
|
||||
self.convert_btn.setEnabled(True)
|
||||
self.progress_bar.setVisible(False)
|
||||
self.status_label.setVisible(False)
|
||||
|
||||
QMessageBox.critical(self, "错误", f"转换失败:\n{error}")
|
||||
logging.error(f"PDF转Word失败: {error}")
|
||||
|
||||
Reference in New Issue
Block a user