初始化
This commit is contained in:
6
tools/__init__.py
Normal file
6
tools/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
工具模块
|
||||
- 图片工具
|
||||
- PDF工具
|
||||
- Excel工具
|
||||
"""
|
||||
2
tools/excel/__init__.py
Normal file
2
tools/excel/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Excel tools module
|
||||
|
||||
375
tools/excel/chart.py
Normal file
375
tools/excel/chart.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Excel图表生成工具
|
||||
- 选择数据列
|
||||
- 图表类型选择(柱状图/折线图/饼图)
|
||||
- matplotlib图表嵌入Qt窗口
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QFrame, QFileDialog, QMessageBox, QComboBox, QListWidget,
|
||||
QListWidgetItem, QAbstractItemView, QSplitter, QGroupBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from ui.workspace import BaseWorkspace, UploadArea
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
HAS_PANDAS = True
|
||||
except ImportError:
|
||||
HAS_PANDAS = False
|
||||
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('QtAgg')
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||||
from matplotlib.figure import Figure
|
||||
import matplotlib.pyplot as plt
|
||||
HAS_MATPLOTLIB = True
|
||||
|
||||
# 设置中文字体
|
||||
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'DejaVu Sans']
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
except ImportError:
|
||||
HAS_MATPLOTLIB = False
|
||||
logging.warning("matplotlib未安装, 图表生成功能不可用")
|
||||
|
||||
|
||||
class ChartCanvas(FigureCanvas if HAS_MATPLOTLIB else QWidget):
|
||||
"""图表画布"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
if HAS_MATPLOTLIB:
|
||||
self.figure = Figure(figsize=(8, 6), facecolor='#1e293b')
|
||||
super().__init__(self.figure)
|
||||
self.axes = self.figure.add_subplot(111)
|
||||
self.configure_axes()
|
||||
else:
|
||||
super().__init__(parent)
|
||||
|
||||
def configure_axes(self):
|
||||
"""配置坐标轴样式"""
|
||||
self.axes.set_facecolor('#0f172a')
|
||||
self.axes.tick_params(colors='#94a3b8')
|
||||
self.axes.xaxis.label.set_color('#e2e8f0')
|
||||
self.axes.yaxis.label.set_color('#e2e8f0')
|
||||
self.axes.title.set_color('white')
|
||||
|
||||
for spine in self.axes.spines.values():
|
||||
spine.set_color('#334155')
|
||||
|
||||
def clear_chart(self):
|
||||
"""清空图表"""
|
||||
if HAS_MATPLOTLIB:
|
||||
self.axes.clear()
|
||||
self.configure_axes()
|
||||
self.draw()
|
||||
|
||||
def draw_bar_chart(self, x_data, y_data, x_label: str, y_label: str, title: str):
|
||||
"""绘制柱状图"""
|
||||
self.clear_chart()
|
||||
|
||||
colors = ['#fbbf24', '#3b82f6', '#22c55e', '#ef4444', '#8b5cf6', '#ec4899']
|
||||
bars = self.axes.bar(x_data, y_data, color=colors[:len(x_data)])
|
||||
|
||||
self.axes.set_xlabel(x_label)
|
||||
self.axes.set_ylabel(y_label)
|
||||
self.axes.set_title(title, fontsize=14, fontweight='bold')
|
||||
|
||||
# 旋转x轴标签
|
||||
self.axes.tick_params(axis='x', rotation=45)
|
||||
self.figure.tight_layout()
|
||||
self.draw()
|
||||
|
||||
def draw_line_chart(self, x_data, y_data, x_label: str, y_label: str, title: str):
|
||||
"""绘制折线图"""
|
||||
self.clear_chart()
|
||||
|
||||
self.axes.plot(x_data, y_data, color='#fbbf24', linewidth=2, marker='o', markersize=6)
|
||||
self.axes.fill_between(x_data, y_data, alpha=0.2, color='#fbbf24')
|
||||
|
||||
self.axes.set_xlabel(x_label)
|
||||
self.axes.set_ylabel(y_label)
|
||||
self.axes.set_title(title, fontsize=14, fontweight='bold')
|
||||
self.axes.grid(True, alpha=0.3, color='#334155')
|
||||
|
||||
self.axes.tick_params(axis='x', rotation=45)
|
||||
self.figure.tight_layout()
|
||||
self.draw()
|
||||
|
||||
def draw_pie_chart(self, labels, values, title: str):
|
||||
"""绘制饼图"""
|
||||
self.clear_chart()
|
||||
|
||||
colors = ['#fbbf24', '#3b82f6', '#22c55e', '#ef4444', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16']
|
||||
|
||||
wedges, texts, autotexts = self.axes.pie(
|
||||
values,
|
||||
labels=labels,
|
||||
autopct='%1.1f%%',
|
||||
colors=colors[:len(values)],
|
||||
textprops={'color': '#e2e8f0'}
|
||||
)
|
||||
|
||||
for autotext in autotexts:
|
||||
autotext.set_color('#0f172a')
|
||||
autotext.set_fontweight('bold')
|
||||
|
||||
self.axes.set_title(title, fontsize=14, fontweight='bold')
|
||||
self.figure.tight_layout()
|
||||
self.draw()
|
||||
|
||||
def save_chart(self, file_path: str):
|
||||
"""保存图表"""
|
||||
if HAS_MATPLOTLIB:
|
||||
self.figure.savefig(file_path, dpi=150, facecolor='#1e293b', edgecolor='none')
|
||||
|
||||
|
||||
class ExcelChartPage(BaseWorkspace):
|
||||
"""Excel图表生成页面"""
|
||||
|
||||
CHART_TYPES = [
|
||||
("📊 柱状图", "bar"),
|
||||
("📈 折线图", "line"),
|
||||
("🥧 饼图", "pie")
|
||||
]
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.excel_path = None
|
||||
self.df = None
|
||||
self.setup_chart_ui()
|
||||
|
||||
def setup_chart_ui(self):
|
||||
"""设置图表UI"""
|
||||
self.history_btn.hide()
|
||||
self.export_btn.clicked.connect(self.export_chart)
|
||||
|
||||
# 上传区域
|
||||
self.upload_area = UploadArea("Excel文件 (*.xlsx *.xls)")
|
||||
self.upload_area.files_dropped.connect(self.on_file_added)
|
||||
self.content_layout.addWidget(self.upload_area)
|
||||
|
||||
# 主工作区
|
||||
self.work_area = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.work_area.setVisible(False)
|
||||
|
||||
# 左侧设置面板
|
||||
settings_frame = QFrame()
|
||||
settings_frame.setObjectName("card")
|
||||
settings_frame.setFixedWidth(280)
|
||||
settings_layout = QVBoxLayout(settings_frame)
|
||||
settings_layout.setContentsMargins(16, 16, 16, 16)
|
||||
settings_layout.setSpacing(16)
|
||||
|
||||
# 文件信息
|
||||
self.file_label = QLabel("")
|
||||
self.file_label.setStyleSheet("color: #fbbf24; font-size: 12px;")
|
||||
self.file_label.setWordWrap(True)
|
||||
settings_layout.addWidget(self.file_label)
|
||||
|
||||
# Sheet选择
|
||||
sheet_group = QGroupBox("📋 工作表")
|
||||
sheet_layout = QVBoxLayout(sheet_group)
|
||||
self.sheet_combo = QComboBox()
|
||||
self.sheet_combo.currentTextChanged.connect(self.on_sheet_changed)
|
||||
sheet_layout.addWidget(self.sheet_combo)
|
||||
settings_layout.addWidget(sheet_group)
|
||||
|
||||
# 图表类型
|
||||
type_group = QGroupBox("📊 图表类型")
|
||||
type_layout = QVBoxLayout(type_group)
|
||||
self.type_combo = QComboBox()
|
||||
for text, value in self.CHART_TYPES:
|
||||
self.type_combo.addItem(text, value)
|
||||
self.type_combo.currentIndexChanged.connect(self.update_chart)
|
||||
type_layout.addWidget(self.type_combo)
|
||||
settings_layout.addWidget(type_group)
|
||||
|
||||
# X轴数据(标签列)
|
||||
x_group = QGroupBox("📌 X轴 / 标签列")
|
||||
x_layout = QVBoxLayout(x_group)
|
||||
self.x_combo = QComboBox()
|
||||
self.x_combo.currentIndexChanged.connect(self.update_chart)
|
||||
x_layout.addWidget(self.x_combo)
|
||||
settings_layout.addWidget(x_group)
|
||||
|
||||
# Y轴数据(数值列)
|
||||
y_group = QGroupBox("📈 Y轴 / 数值列")
|
||||
y_layout = QVBoxLayout(y_group)
|
||||
self.y_combo = QComboBox()
|
||||
self.y_combo.currentIndexChanged.connect(self.update_chart)
|
||||
y_layout.addWidget(self.y_combo)
|
||||
settings_layout.addWidget(y_group)
|
||||
|
||||
settings_layout.addStretch()
|
||||
|
||||
# 更换文件按钮
|
||||
change_btn = QPushButton("📂 更换文件")
|
||||
change_btn.setObjectName("secondary_btn")
|
||||
change_btn.clicked.connect(self.change_file)
|
||||
settings_layout.addWidget(change_btn)
|
||||
|
||||
# 生成图表按钮
|
||||
self.generate_btn = QPushButton("⚡ 生成图表")
|
||||
self.generate_btn.setObjectName("primary_btn")
|
||||
self.generate_btn.setMinimumHeight(40)
|
||||
self.generate_btn.clicked.connect(self.update_chart)
|
||||
settings_layout.addWidget(self.generate_btn)
|
||||
|
||||
self.work_area.addWidget(settings_frame)
|
||||
|
||||
# 右侧图表区
|
||||
chart_frame = QFrame()
|
||||
chart_frame.setObjectName("card")
|
||||
chart_layout = QVBoxLayout(chart_frame)
|
||||
chart_layout.setContentsMargins(16, 16, 16, 16)
|
||||
|
||||
if HAS_MATPLOTLIB:
|
||||
self.chart_canvas = ChartCanvas()
|
||||
chart_layout.addWidget(self.chart_canvas)
|
||||
else:
|
||||
no_chart_label = QLabel("matplotlib未安装,无法显示图表")
|
||||
no_chart_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
no_chart_label.setStyleSheet("color: #ef4444;")
|
||||
chart_layout.addWidget(no_chart_label)
|
||||
|
||||
self.work_area.addWidget(chart_frame)
|
||||
self.work_area.setSizes([280, 700])
|
||||
|
||||
self.content_layout.addWidget(self.work_area, 1)
|
||||
|
||||
def on_file_added(self, files: list):
|
||||
"""文件添加"""
|
||||
if not files:
|
||||
return
|
||||
|
||||
excel_file = None
|
||||
for f in files:
|
||||
if f.lower().endswith(('.xlsx', '.xls')):
|
||||
excel_file = f
|
||||
break
|
||||
|
||||
if not excel_file:
|
||||
QMessageBox.warning(self, "提示", "请选择Excel文件")
|
||||
return
|
||||
|
||||
self.load_excel(excel_file)
|
||||
|
||||
def load_excel(self, file_path: str):
|
||||
"""加载Excel"""
|
||||
if not HAS_PANDAS:
|
||||
QMessageBox.critical(self, "错误", "pandas未安装,无法读取Excel")
|
||||
return
|
||||
|
||||
try:
|
||||
self.excel_path = file_path
|
||||
self.excel_file = pd.ExcelFile(file_path)
|
||||
|
||||
# 更新sheet下拉框
|
||||
self.sheet_combo.clear()
|
||||
self.sheet_combo.addItems(self.excel_file.sheet_names)
|
||||
|
||||
self.file_label.setText(f"📁 {Path(file_path).name}")
|
||||
|
||||
self.upload_area.setVisible(False)
|
||||
self.work_area.setVisible(True)
|
||||
|
||||
logging.info(f"加载Excel: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "错误", f"加载Excel失败:\n{e}")
|
||||
logging.error(f"加载Excel失败: {e}")
|
||||
|
||||
def on_sheet_changed(self, sheet_name: str):
|
||||
"""Sheet变化"""
|
||||
if not sheet_name:
|
||||
return
|
||||
|
||||
try:
|
||||
self.df = pd.read_excel(self.excel_file, sheet_name=sheet_name)
|
||||
|
||||
# 更新列下拉框
|
||||
columns = list(self.df.columns)
|
||||
|
||||
self.x_combo.clear()
|
||||
self.x_combo.addItems([str(c) for c in columns])
|
||||
|
||||
self.y_combo.clear()
|
||||
# 尝试只添加数值列
|
||||
numeric_cols = self.df.select_dtypes(include=['number']).columns.tolist()
|
||||
if numeric_cols:
|
||||
self.y_combo.addItems([str(c) for c in numeric_cols])
|
||||
else:
|
||||
self.y_combo.addItems([str(c) for c in columns])
|
||||
|
||||
self.update_chart()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"读取工作表失败: {e}")
|
||||
|
||||
def update_chart(self):
|
||||
"""更新图表"""
|
||||
if not HAS_MATPLOTLIB or self.df is None:
|
||||
return
|
||||
|
||||
x_col = self.x_combo.currentText()
|
||||
y_col = self.y_combo.currentText()
|
||||
chart_type = self.type_combo.currentData()
|
||||
|
||||
if not x_col or not y_col:
|
||||
return
|
||||
|
||||
try:
|
||||
# 获取数据
|
||||
x_data = self.df[x_col].astype(str).tolist()
|
||||
y_data = pd.to_numeric(self.df[y_col], errors='coerce').fillna(0).tolist()
|
||||
|
||||
# 限制数据量
|
||||
max_items = 20
|
||||
if len(x_data) > max_items:
|
||||
x_data = x_data[:max_items]
|
||||
y_data = y_data[:max_items]
|
||||
|
||||
title = f"{y_col} by {x_col}"
|
||||
|
||||
if chart_type == "bar":
|
||||
self.chart_canvas.draw_bar_chart(x_data, y_data, x_col, y_col, title)
|
||||
elif chart_type == "line":
|
||||
self.chart_canvas.draw_line_chart(x_data, y_data, x_col, y_col, title)
|
||||
elif chart_type == "pie":
|
||||
self.chart_canvas.draw_pie_chart(x_data, y_data, title)
|
||||
|
||||
logging.debug(f"生成图表: {chart_type}, X={x_col}, Y={y_col}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"生成图表失败: {e}")
|
||||
QMessageBox.warning(self, "警告", f"生成图表失败:\n{e}")
|
||||
|
||||
def export_chart(self):
|
||||
"""导出图表"""
|
||||
if not HAS_MATPLOTLIB:
|
||||
return
|
||||
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "导出图表", "chart.png", "PNG图片 (*.png);;JPEG图片 (*.jpg);;PDF文档 (*.pdf)"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
self.chart_canvas.save_chart(file_path)
|
||||
QMessageBox.information(self, "成功", f"图表已导出到:\n{file_path}")
|
||||
logging.info(f"图表导出: {file_path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "错误", f"导出失败:\n{e}")
|
||||
|
||||
def change_file(self):
|
||||
"""更换文件"""
|
||||
self.work_area.setVisible(False)
|
||||
self.upload_area.setVisible(True)
|
||||
self.upload_area.open_file_dialog()
|
||||
|
||||
248
tools/excel/preview.py
Normal file
248
tools/excel/preview.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Excel预览工具
|
||||
- 读取.xlsx/.xls文件
|
||||
- QTableWidget显示表格数据
|
||||
- 多Sheet标签页切换
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QFrame, QFileDialog, QMessageBox, QTableWidget, QTableWidgetItem,
|
||||
QTabWidget, QHeaderView, QAbstractItemView
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QFont, QColor
|
||||
|
||||
from ui.workspace import BaseWorkspace, UploadArea
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
HAS_PANDAS = True
|
||||
except ImportError:
|
||||
HAS_PANDAS = False
|
||||
logging.warning("pandas未安装, Excel预览功能不可用")
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
HAS_OPENPYXL = True
|
||||
except ImportError:
|
||||
HAS_OPENPYXL = False
|
||||
logging.warning("openpyxl未安装, Excel预览功能不可用")
|
||||
|
||||
|
||||
class ExcelLoadWorker(QThread):
|
||||
"""Excel加载线程"""
|
||||
finished = Signal(dict) # {sheet_name: DataFrame}
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# 使用pandas读取所有sheet
|
||||
excel_file = pd.ExcelFile(self.file_path)
|
||||
sheets_data = {}
|
||||
|
||||
for sheet_name in excel_file.sheet_names:
|
||||
df = pd.read_excel(excel_file, sheet_name=sheet_name)
|
||||
sheets_data[sheet_name] = df
|
||||
|
||||
self.finished.emit(sheets_data)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"加载Excel失败: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class SheetTable(QTableWidget):
|
||||
"""Sheet表格组件"""
|
||||
|
||||
def __init__(self, df: 'pd.DataFrame', parent=None):
|
||||
super().__init__(parent)
|
||||
self.setup_table(df)
|
||||
|
||||
def setup_table(self, df: 'pd.DataFrame'):
|
||||
"""设置表格数据"""
|
||||
# 设置行列数
|
||||
self.setRowCount(len(df))
|
||||
self.setColumnCount(len(df.columns))
|
||||
|
||||
# 设置表头
|
||||
headers = [str(col) for col in df.columns]
|
||||
self.setHorizontalHeaderLabels(headers)
|
||||
|
||||
# 填充数据
|
||||
for row_idx, (_, row) in enumerate(df.iterrows()):
|
||||
for col_idx, value in enumerate(row):
|
||||
item = QTableWidgetItem(str(value) if pd.notna(value) else "")
|
||||
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) # 只读
|
||||
self.setItem(row_idx, col_idx, item)
|
||||
|
||||
# 设置样式
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.horizontalHeader().setStretchLastSection(True)
|
||||
self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||
self.verticalHeader().setDefaultSectionSize(35)
|
||||
|
||||
# 自动调整列宽
|
||||
self.resizeColumnsToContents()
|
||||
|
||||
# 限制最大列宽
|
||||
for col in range(self.columnCount()):
|
||||
if self.columnWidth(col) > 300:
|
||||
self.setColumnWidth(col, 300)
|
||||
|
||||
|
||||
class ExcelPreviewPage(BaseWorkspace):
|
||||
"""Excel预览页面"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.excel_path = None
|
||||
self.sheets_data = {}
|
||||
self.setup_preview_ui()
|
||||
|
||||
def setup_preview_ui(self):
|
||||
"""设置预览UI"""
|
||||
self.history_btn.hide()
|
||||
|
||||
# 上传区域
|
||||
self.upload_area = UploadArea("Excel文件 (*.xlsx *.xls)")
|
||||
self.upload_area.files_dropped.connect(self.on_file_added)
|
||||
self.content_layout.addWidget(self.upload_area)
|
||||
|
||||
# 预览区域
|
||||
self.preview_frame = QFrame()
|
||||
self.preview_frame.setObjectName("card")
|
||||
self.preview_frame.setVisible(False)
|
||||
preview_layout = QVBoxLayout(self.preview_frame)
|
||||
preview_layout.setContentsMargins(0, 0, 0, 0)
|
||||
preview_layout.setSpacing(0)
|
||||
|
||||
# 工具栏
|
||||
toolbar = QWidget()
|
||||
toolbar.setStyleSheet("background: rgba(15, 23, 42, 0.5); border-bottom: 1px solid #334155;")
|
||||
toolbar_layout = QHBoxLayout(toolbar)
|
||||
toolbar_layout.setContentsMargins(16, 12, 16, 12)
|
||||
|
||||
# 文件信息
|
||||
self.file_label = QLabel("")
|
||||
self.file_label.setStyleSheet("color: white; font-weight: 500;")
|
||||
toolbar_layout.addWidget(self.file_label)
|
||||
|
||||
toolbar_layout.addStretch()
|
||||
|
||||
# 统计信息
|
||||
self.stats_label = QLabel("")
|
||||
self.stats_label.setStyleSheet("color: #64748b; font-size: 12px;")
|
||||
toolbar_layout.addWidget(self.stats_label)
|
||||
|
||||
# 重新选择按钮
|
||||
change_btn = QPushButton("📂 更换文件")
|
||||
change_btn.setObjectName("secondary_btn")
|
||||
change_btn.clicked.connect(self.change_file)
|
||||
toolbar_layout.addWidget(change_btn)
|
||||
|
||||
preview_layout.addWidget(toolbar)
|
||||
|
||||
# Sheet标签页
|
||||
self.tab_widget = QTabWidget()
|
||||
self.tab_widget.setStyleSheet("""
|
||||
QTabWidget::pane {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
QTabBar::tab:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
color: #fbbf24;
|
||||
border-bottom: 2px solid #fbbf24;
|
||||
}
|
||||
""")
|
||||
preview_layout.addWidget(self.tab_widget, 1)
|
||||
|
||||
self.content_layout.addWidget(self.preview_frame, 1)
|
||||
|
||||
def on_file_added(self, files: list):
|
||||
"""文件添加"""
|
||||
if not files:
|
||||
return
|
||||
|
||||
excel_file = None
|
||||
for f in files:
|
||||
if f.lower().endswith(('.xlsx', '.xls')):
|
||||
excel_file = f
|
||||
break
|
||||
|
||||
if not excel_file:
|
||||
QMessageBox.warning(self, "提示", "请选择Excel文件")
|
||||
return
|
||||
|
||||
self.load_excel(excel_file)
|
||||
|
||||
def load_excel(self, file_path: str):
|
||||
"""加载Excel文件"""
|
||||
if not HAS_PANDAS or not HAS_OPENPYXL:
|
||||
QMessageBox.critical(self, "错误", "pandas或openpyxl未安装,无法预览Excel文件")
|
||||
return
|
||||
|
||||
self.excel_path = file_path
|
||||
self.file_label.setText(f"📊 {Path(file_path).name}")
|
||||
|
||||
# 清空现有标签页
|
||||
self.tab_widget.clear()
|
||||
self.sheets_data.clear()
|
||||
|
||||
# 启动加载线程
|
||||
self.worker = ExcelLoadWorker(file_path)
|
||||
self.worker.finished.connect(self.on_load_finished)
|
||||
self.worker.error.connect(self.on_load_error)
|
||||
self.worker.start()
|
||||
|
||||
logging.info(f"开始加载Excel: {file_path}")
|
||||
|
||||
def on_load_finished(self, sheets_data: dict):
|
||||
"""加载完成"""
|
||||
self.sheets_data = sheets_data
|
||||
self.preview_frame.setVisible(True)
|
||||
self.upload_area.setVisible(False)
|
||||
|
||||
total_rows = 0
|
||||
total_cols = 0
|
||||
|
||||
# 创建标签页
|
||||
for sheet_name, df in sheets_data.items():
|
||||
table = SheetTable(df)
|
||||
self.tab_widget.addTab(table, f"📋 {sheet_name}")
|
||||
total_rows += len(df)
|
||||
total_cols = max(total_cols, len(df.columns))
|
||||
|
||||
self.stats_label.setText(
|
||||
f"{len(sheets_data)} 个工作表 | 共 {total_rows} 行 | {total_cols} 列"
|
||||
)
|
||||
|
||||
logging.info(f"Excel加载完成: {len(sheets_data)} 个工作表")
|
||||
|
||||
def on_load_error(self, error: str):
|
||||
"""加载错误"""
|
||||
QMessageBox.critical(self, "错误", f"加载Excel失败:\n{error}")
|
||||
logging.error(f"加载Excel失败: {error}")
|
||||
|
||||
def change_file(self):
|
||||
"""更换文件"""
|
||||
self.preview_frame.setVisible(False)
|
||||
self.upload_area.setVisible(True)
|
||||
self.upload_area.open_file_dialog()
|
||||
|
||||
15
tools/image/__init__.py
Normal file
15
tools/image/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
图片工具模块
|
||||
- 压缩
|
||||
- 格式转换
|
||||
- 水印
|
||||
"""
|
||||
from .compress import ImageCompressPage
|
||||
from .convert import ImageConvertPage
|
||||
from .watermark import ImageWatermarkPage
|
||||
|
||||
__all__ = [
|
||||
'ImageCompressPage',
|
||||
'ImageConvertPage',
|
||||
'ImageWatermarkPage'
|
||||
]
|
||||
630
tools/image/compress.py
Normal file
630
tools/image/compress.py
Normal file
@@ -0,0 +1,630 @@
|
||||
"""
|
||||
图片压缩工具 - 极致优化版
|
||||
专注于:在保证视觉效果不降低的情况下,极致压缩文件大小
|
||||
- 保持原有格式(不转换格式)
|
||||
- 多种压缩模式
|
||||
- 智能参数优化
|
||||
"""
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QSlider, QFrame, QFileDialog, QMessageBox,
|
||||
QProgressBar, QListWidget, QListWidgetItem, QCheckBox,
|
||||
QGroupBox, QRadioButton, QButtonGroup, QComboBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from ui.workspace import BaseWorkspace, UploadArea
|
||||
from ui.image_preview import DualPreviewWidget
|
||||
from core.config import config
|
||||
|
||||
|
||||
class SmartCompressor:
|
||||
"""智能图片压缩器 - 保持原格式,极致压缩"""
|
||||
|
||||
# 压缩模式
|
||||
MODE_VISUALLY_LOSSLESS = "visually" # 视觉无损(推荐)
|
||||
MODE_BALANCED = "balanced" # 均衡模式
|
||||
MODE_MAXIMUM = "maximum" # 极致压缩
|
||||
MODE_LOSSLESS = "lossless" # 完全无损
|
||||
|
||||
@classmethod
|
||||
def compress(cls, img: Image.Image, original_format: str, mode: str,
|
||||
quality_override: int = None) -> tuple:
|
||||
"""
|
||||
压缩图片(保持原格式)
|
||||
|
||||
Args:
|
||||
img: PIL Image对象
|
||||
original_format: 原始格式 (jpeg/png/webp)
|
||||
mode: 压缩模式
|
||||
quality_override: 手动覆盖质量值
|
||||
|
||||
Returns:
|
||||
(compressed_data, output_extension)
|
||||
"""
|
||||
# 标准化格式名
|
||||
fmt = original_format.lower()
|
||||
if fmt in ['jpg', 'jpeg']:
|
||||
return cls._compress_jpeg(img, mode, quality_override)
|
||||
elif fmt == 'png':
|
||||
return cls._compress_png(img, mode)
|
||||
elif fmt == 'webp':
|
||||
return cls._compress_webp(img, mode, quality_override)
|
||||
elif fmt == 'gif':
|
||||
return cls._compress_gif(img)
|
||||
else:
|
||||
# 未知格式,转为JPEG压缩
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
if img.mode in ('RGBA', 'LA'):
|
||||
background.paste(img, mask=img.split()[-1])
|
||||
else:
|
||||
background.paste(img)
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
return cls._compress_jpeg(img, mode, quality_override)
|
||||
|
||||
@classmethod
|
||||
def _compress_jpeg(cls, img: Image.Image, mode: str, quality_override: int = None) -> tuple:
|
||||
"""JPEG极致压缩"""
|
||||
# 确保是RGB模式
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
if img.mode in ('RGBA', 'LA'):
|
||||
background.paste(img, mask=img.split()[-1])
|
||||
else:
|
||||
background.paste(img)
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
# 根据模式选择参数
|
||||
if quality_override is not None:
|
||||
quality = quality_override
|
||||
else:
|
||||
quality = {
|
||||
cls.MODE_LOSSLESS: 100,
|
||||
cls.MODE_VISUALLY_LOSSLESS: 88, # 视觉无损的最佳质量
|
||||
cls.MODE_BALANCED: 80,
|
||||
cls.MODE_MAXIMUM: 70,
|
||||
}.get(mode, 85)
|
||||
|
||||
# 子采样设置:quality高时用4:4:4保持质量
|
||||
if quality >= 90:
|
||||
subsampling = 0 # 4:4:4
|
||||
elif quality >= 80:
|
||||
subsampling = 1 # 4:2:2
|
||||
else:
|
||||
subsampling = 2 # 4:2:0
|
||||
|
||||
img.save(
|
||||
buffer,
|
||||
"JPEG",
|
||||
quality=quality,
|
||||
optimize=True,
|
||||
subsampling=subsampling,
|
||||
progressive=True
|
||||
)
|
||||
|
||||
return buffer.getvalue(), ".jpg"
|
||||
|
||||
@classmethod
|
||||
def _compress_png(cls, img: Image.Image, mode: str) -> tuple:
|
||||
"""PNG压缩(无损,但优化)"""
|
||||
buffer = io.BytesIO()
|
||||
|
||||
# PNG是无损格式,只能通过优化来减小
|
||||
# 对于极致压缩模式,尝试减少颜色
|
||||
if mode == cls.MODE_MAXIMUM:
|
||||
# 检查是否可以用调色板模式
|
||||
if img.mode == 'RGBA':
|
||||
colors = img.getcolors(maxcolors=256)
|
||||
if colors:
|
||||
img = img.convert('P', palette=Image.Palette.ADAPTIVE, colors=len(colors))
|
||||
elif img.mode == 'RGB':
|
||||
colors = img.getcolors(maxcolors=256)
|
||||
if colors:
|
||||
img = img.convert('P', palette=Image.Palette.ADAPTIVE, colors=len(colors))
|
||||
|
||||
img.save(
|
||||
buffer,
|
||||
"PNG",
|
||||
optimize=True,
|
||||
compress_level=9 # 最大压缩级别
|
||||
)
|
||||
|
||||
return buffer.getvalue(), ".png"
|
||||
|
||||
@classmethod
|
||||
def _compress_webp(cls, img: Image.Image, mode: str, quality_override: int = None) -> tuple:
|
||||
"""WebP压缩"""
|
||||
buffer = io.BytesIO()
|
||||
|
||||
if mode == cls.MODE_LOSSLESS:
|
||||
img.save(buffer, "WEBP", lossless=True, quality=100)
|
||||
else:
|
||||
if quality_override is not None:
|
||||
quality = quality_override
|
||||
else:
|
||||
quality = {
|
||||
cls.MODE_VISUALLY_LOSSLESS: 88,
|
||||
cls.MODE_BALANCED: 80,
|
||||
cls.MODE_MAXIMUM: 70,
|
||||
}.get(mode, 85)
|
||||
|
||||
img.save(
|
||||
buffer,
|
||||
"WEBP",
|
||||
quality=quality,
|
||||
method=6 # 最慢但压缩率最高
|
||||
)
|
||||
|
||||
return buffer.getvalue(), ".webp"
|
||||
|
||||
@classmethod
|
||||
def _compress_gif(cls, img: Image.Image) -> tuple:
|
||||
"""GIF保持原样(GIF压缩会丢失动画)"""
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, "GIF", optimize=True)
|
||||
return buffer.getvalue(), ".gif"
|
||||
|
||||
|
||||
class CompressWorker(QThread):
|
||||
"""压缩工作线程"""
|
||||
progress = Signal(int, int)
|
||||
file_processed = Signal(str, bytes, dict)
|
||||
finished = Signal(list)
|
||||
|
||||
def __init__(self, files: list, compress_mode: str, quality: int = None,
|
||||
resize_percent: int = 100):
|
||||
super().__init__()
|
||||
self.files = files
|
||||
self.compress_mode = compress_mode
|
||||
self.quality = quality
|
||||
self.resize_percent = resize_percent
|
||||
|
||||
def run(self):
|
||||
results = []
|
||||
total = len(self.files)
|
||||
|
||||
for i, file_path in enumerate(self.files):
|
||||
try:
|
||||
result = self.compress_image(file_path)
|
||||
results.append(result)
|
||||
|
||||
if result.get("success") and result.get("data"):
|
||||
self.file_processed.emit(
|
||||
file_path,
|
||||
result["data"],
|
||||
{
|
||||
"size": result["compressed_size"],
|
||||
"name": result.get("output_name", ""),
|
||||
"original_size": result["original_size"]
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"压缩失败 {file_path}: {e}")
|
||||
results.append({
|
||||
"file": file_path,
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
self.progress.emit(i + 1, total)
|
||||
|
||||
self.finished.emit(results)
|
||||
|
||||
def compress_image(self, file_path: str) -> dict:
|
||||
"""压缩单个图片"""
|
||||
original_size = os.path.getsize(file_path)
|
||||
original_ext = Path(file_path).suffix.lower()
|
||||
|
||||
# 获取原始格式
|
||||
original_format = original_ext.lstrip('.')
|
||||
|
||||
with Image.open(file_path) as img:
|
||||
original_width, original_height = img.size
|
||||
|
||||
# 调整尺寸(如果需要)
|
||||
if self.resize_percent < 100:
|
||||
new_width = int(original_width * self.resize_percent / 100)
|
||||
new_height = int(original_height * self.resize_percent / 100)
|
||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# 压缩(保持原格式)
|
||||
compressed_data, ext = SmartCompressor.compress(
|
||||
img,
|
||||
original_format,
|
||||
self.compress_mode,
|
||||
self.quality
|
||||
)
|
||||
|
||||
compressed_size = len(compressed_data)
|
||||
|
||||
# 如果压缩后反而变大,使用原文件
|
||||
if compressed_size >= original_size and self.resize_percent == 100:
|
||||
with open(file_path, 'rb') as f:
|
||||
compressed_data = f.read()
|
||||
compressed_size = original_size
|
||||
ext = original_ext
|
||||
|
||||
output_name = Path(file_path).stem + "_compressed" + ext
|
||||
|
||||
return {
|
||||
"file": file_path,
|
||||
"output_name": output_name,
|
||||
"original_size": original_size,
|
||||
"compressed_size": compressed_size,
|
||||
"ratio": (1 - compressed_size / original_size) * 100 if original_size > 0 else 0,
|
||||
"success": True,
|
||||
"data": compressed_data
|
||||
}
|
||||
|
||||
|
||||
class ImageCompressPage(BaseWorkspace):
|
||||
"""图片压缩页面"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.files = []
|
||||
self.current_file_index = 0
|
||||
self.processed_results = {}
|
||||
self.setup_compress_ui()
|
||||
|
||||
def setup_compress_ui(self):
|
||||
"""设置压缩UI"""
|
||||
self.history_btn.hide()
|
||||
self.export_btn.setText("💾 批量保存")
|
||||
self.export_btn.clicked.connect(self.batch_save)
|
||||
|
||||
# 上传区域
|
||||
self.upload_area = UploadArea("图片文件 (*.jpg *.jpeg *.png *.webp *.gif *.bmp)")
|
||||
self.upload_area.files_dropped.connect(self.on_files_added)
|
||||
self.content_layout.addWidget(self.upload_area)
|
||||
|
||||
# 主内容区
|
||||
content_widget = QWidget()
|
||||
content_layout = QHBoxLayout(content_widget)
|
||||
content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
content_layout.setSpacing(24)
|
||||
|
||||
# 左侧 - 预览区
|
||||
self.preview_widget = DualPreviewWidget()
|
||||
self.preview_widget.save_requested.connect(self.on_file_saved)
|
||||
content_layout.addWidget(self.preview_widget, 2)
|
||||
|
||||
# 右侧设置区
|
||||
settings_frame = QFrame()
|
||||
settings_frame.setObjectName("card")
|
||||
settings_frame.setFixedWidth(300)
|
||||
settings_frame.setStyleSheet("""
|
||||
#card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 16px;
|
||||
}
|
||||
""")
|
||||
settings_layout = QVBoxLayout(settings_frame)
|
||||
settings_layout.setContentsMargins(20, 20, 20, 20)
|
||||
settings_layout.setSpacing(16)
|
||||
|
||||
# ====== 压缩模式 ======
|
||||
mode_group = QGroupBox("🎯 压缩模式")
|
||||
mode_group.setStyleSheet("""
|
||||
QGroupBox {
|
||||
font-weight: bold;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
""")
|
||||
mode_layout = QVBoxLayout(mode_group)
|
||||
mode_layout.setSpacing(6)
|
||||
|
||||
self.mode_group = QButtonGroup(self)
|
||||
|
||||
modes = [
|
||||
("visually", "🔒 视觉无损(推荐)", "肉眼几乎看不出差异", True),
|
||||
("balanced", "⚖️ 均衡模式", "平衡质量与压缩率"),
|
||||
("maximum", "🚀 极致压缩", "最大压缩,可能有轻微损失"),
|
||||
("lossless", "💎 完全无损", "100%保留原质量"),
|
||||
]
|
||||
|
||||
for i, mode_data in enumerate(modes):
|
||||
mode_id, text, desc = mode_data[:3]
|
||||
is_default = len(mode_data) > 3 and mode_data[3]
|
||||
|
||||
radio = QRadioButton(text)
|
||||
radio.setProperty("mode_id", mode_id)
|
||||
radio.setStyleSheet("color: #e2e8f0; font-size: 12px;")
|
||||
if is_default:
|
||||
radio.setChecked(True)
|
||||
self.mode_group.addButton(radio, i)
|
||||
mode_layout.addWidget(radio)
|
||||
|
||||
desc_label = QLabel(f" {desc}")
|
||||
desc_label.setStyleSheet("color: #64748b; font-size: 10px;")
|
||||
mode_layout.addWidget(desc_label)
|
||||
|
||||
settings_layout.addWidget(mode_group)
|
||||
|
||||
# ====== 高级设置 ======
|
||||
advanced_group = QGroupBox("⚙️ 高级选项")
|
||||
advanced_group.setStyleSheet(mode_group.styleSheet())
|
||||
advanced_layout = QVBoxLayout(advanced_group)
|
||||
advanced_layout.setSpacing(10)
|
||||
|
||||
# 手动质量
|
||||
self.manual_quality_check = QCheckBox("手动指定质量")
|
||||
self.manual_quality_check.setStyleSheet("color: #cbd5e1; font-size: 12px;")
|
||||
self.manual_quality_check.stateChanged.connect(self.on_manual_quality_changed)
|
||||
advanced_layout.addWidget(self.manual_quality_check)
|
||||
|
||||
quality_row = QHBoxLayout()
|
||||
self.quality_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.quality_slider.setRange(50, 100)
|
||||
self.quality_slider.setValue(85)
|
||||
self.quality_slider.setEnabled(False)
|
||||
self.quality_slider.valueChanged.connect(self.on_quality_changed)
|
||||
quality_row.addWidget(self.quality_slider, 1)
|
||||
|
||||
self.quality_label = QLabel("85%")
|
||||
self.quality_label.setStyleSheet("color: #fbbf24; font-weight: bold; min-width: 35px;")
|
||||
quality_row.addWidget(self.quality_label)
|
||||
advanced_layout.addLayout(quality_row)
|
||||
|
||||
# 缩放
|
||||
resize_row = QHBoxLayout()
|
||||
resize_row.addWidget(QLabel("尺寸:"))
|
||||
self.resize_combo = QComboBox()
|
||||
self.resize_combo.addItem("100% 原尺寸", 100)
|
||||
self.resize_combo.addItem("75%", 75)
|
||||
self.resize_combo.addItem("50%", 50)
|
||||
resize_row.addWidget(self.resize_combo, 1)
|
||||
advanced_layout.addLayout(resize_row)
|
||||
|
||||
settings_layout.addWidget(advanced_group)
|
||||
|
||||
# ====== 文件列表 ======
|
||||
files_header = QHBoxLayout()
|
||||
files_label = QLabel("📁 待压缩文件")
|
||||
files_label.setStyleSheet("color: #e2e8f0; font-weight: bold; font-size: 12px;")
|
||||
files_header.addWidget(files_label)
|
||||
|
||||
self.files_count = QLabel("0")
|
||||
self.files_count.setStyleSheet("color: #fbbf24;")
|
||||
files_header.addWidget(self.files_count)
|
||||
files_header.addStretch()
|
||||
|
||||
clear_btn = QPushButton("清空")
|
||||
clear_btn.setObjectName("secondary_btn")
|
||||
clear_btn.setFixedWidth(50)
|
||||
clear_btn.clicked.connect(self.clear_files)
|
||||
files_header.addWidget(clear_btn)
|
||||
|
||||
settings_layout.addLayout(files_header)
|
||||
|
||||
self.files_list = QListWidget()
|
||||
self.files_list.setMaximumHeight(100)
|
||||
self.files_list.itemClicked.connect(self.on_file_clicked)
|
||||
settings_layout.addWidget(self.files_list)
|
||||
|
||||
settings_layout.addStretch()
|
||||
|
||||
# 进度条
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
settings_layout.addWidget(self.progress_bar)
|
||||
|
||||
# 预览按钮
|
||||
self.preview_btn = QPushButton("👁️ 预览效果")
|
||||
self.preview_btn.setObjectName("secondary_btn")
|
||||
self.preview_btn.setMinimumHeight(38)
|
||||
self.preview_btn.clicked.connect(self.preview_current)
|
||||
settings_layout.addWidget(self.preview_btn)
|
||||
|
||||
# 开始压缩按钮
|
||||
self.compress_btn = QPushButton("⚡ 开始压缩")
|
||||
self.compress_btn.setObjectName("primary_btn")
|
||||
self.compress_btn.setMinimumHeight(45)
|
||||
self.compress_btn.setFont(QFont("Microsoft YaHei", 12, QFont.Weight.Bold))
|
||||
self.compress_btn.clicked.connect(self.start_compress_all)
|
||||
settings_layout.addWidget(self.compress_btn)
|
||||
|
||||
content_layout.addWidget(settings_frame)
|
||||
|
||||
self.content_layout.addWidget(content_widget, 1)
|
||||
|
||||
def get_compress_settings(self) -> dict:
|
||||
"""获取压缩设置"""
|
||||
selected_btn = self.mode_group.checkedButton()
|
||||
mode = selected_btn.property("mode_id") if selected_btn else "visually"
|
||||
|
||||
quality = None
|
||||
if self.manual_quality_check.isChecked():
|
||||
quality = self.quality_slider.value()
|
||||
|
||||
resize_percent = self.resize_combo.currentData()
|
||||
|
||||
return {"mode": mode, "quality": quality, "resize": resize_percent}
|
||||
|
||||
def on_manual_quality_changed(self, state):
|
||||
self.quality_slider.setEnabled(state == Qt.CheckState.Checked.value)
|
||||
|
||||
def on_quality_changed(self, value: int):
|
||||
self.quality_label.setText(f"{value}%")
|
||||
|
||||
def on_files_added(self, files: list):
|
||||
valid_exts = ('.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp')
|
||||
for file_path in files:
|
||||
if file_path.lower().endswith(valid_exts):
|
||||
if file_path not in self.files:
|
||||
self.files.append(file_path)
|
||||
size = os.path.getsize(file_path)
|
||||
size_str = self.format_size(size)
|
||||
item = QListWidgetItem(f"📷 {Path(file_path).name} ({size_str})")
|
||||
item.setData(Qt.ItemDataRole.UserRole, file_path)
|
||||
self.files_list.addItem(item)
|
||||
|
||||
self.files_count.setText(str(len(self.files)))
|
||||
|
||||
if self.files:
|
||||
self.files_list.setCurrentRow(0)
|
||||
self.preview_widget.set_original(self.files[0])
|
||||
self.current_file_index = 0
|
||||
|
||||
def on_file_clicked(self, item: QListWidgetItem):
|
||||
file_path = item.data(Qt.ItemDataRole.UserRole)
|
||||
self.current_file_index = self.files.index(file_path)
|
||||
self.preview_widget.set_original(file_path)
|
||||
|
||||
if file_path in self.processed_results:
|
||||
result = self.processed_results[file_path]
|
||||
self.preview_widget.set_result(
|
||||
result["data"],
|
||||
{"size": result["compressed_size"], "name": result["output_name"]},
|
||||
result["output_name"]
|
||||
)
|
||||
|
||||
def clear_files(self):
|
||||
self.files.clear()
|
||||
self.files_list.clear()
|
||||
self.files_count.setText("0")
|
||||
self.processed_results.clear()
|
||||
self.preview_widget.clear()
|
||||
|
||||
def preview_current(self):
|
||||
if not self.files:
|
||||
QMessageBox.warning(self, "提示", "请先添加要压缩的图片")
|
||||
return
|
||||
|
||||
settings = self.get_compress_settings()
|
||||
file_path = self.files[self.current_file_index]
|
||||
|
||||
self.preview_btn.setEnabled(False)
|
||||
self.preview_btn.setText("处理中...")
|
||||
|
||||
self.worker = CompressWorker(
|
||||
[file_path], settings["mode"], settings["quality"], settings["resize"]
|
||||
)
|
||||
self.worker.file_processed.connect(self.on_preview_ready)
|
||||
self.worker.finished.connect(lambda: self.preview_btn.setEnabled(True))
|
||||
self.worker.finished.connect(lambda: self.preview_btn.setText("👁️ 预览效果"))
|
||||
self.worker.start()
|
||||
|
||||
def on_preview_ready(self, file_path: str, data: bytes, info: dict):
|
||||
output_name = info.get("name", Path(file_path).stem + "_compressed.jpg")
|
||||
self.preview_widget.set_result(data, info, output_name)
|
||||
self.processed_results[file_path] = {
|
||||
"data": data,
|
||||
"compressed_size": info.get("size", len(data)),
|
||||
"output_name": output_name
|
||||
}
|
||||
|
||||
def start_compress_all(self):
|
||||
if not self.files:
|
||||
QMessageBox.warning(self, "提示", "请先添加要压缩的图片")
|
||||
return
|
||||
|
||||
settings = self.get_compress_settings()
|
||||
|
||||
self.compress_btn.setEnabled(False)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
|
||||
self.worker = CompressWorker(
|
||||
self.files, settings["mode"], settings["quality"], settings["resize"]
|
||||
)
|
||||
self.worker.progress.connect(self.on_progress)
|
||||
self.worker.file_processed.connect(self.on_file_processed)
|
||||
self.worker.finished.connect(self.on_compress_finished)
|
||||
self.worker.start()
|
||||
|
||||
def on_progress(self, current: int, total: int):
|
||||
self.progress_bar.setValue(int(current / total * 100))
|
||||
|
||||
def on_file_processed(self, file_path: str, data: bytes, info: dict):
|
||||
output_name = info.get("name", Path(file_path).stem + "_compressed.jpg")
|
||||
self.processed_results[file_path] = {
|
||||
"data": data,
|
||||
"compressed_size": info.get("size", len(data)),
|
||||
"output_name": output_name,
|
||||
"original_size": info.get("original_size", 0)
|
||||
}
|
||||
|
||||
if self.files.index(file_path) == self.current_file_index:
|
||||
self.preview_widget.set_result(data, info, output_name)
|
||||
|
||||
def on_compress_finished(self, results: list):
|
||||
self.compress_btn.setEnabled(True)
|
||||
self.progress_bar.setVisible(False)
|
||||
|
||||
success = sum(1 for r in results if r.get("success"))
|
||||
total_orig = sum(r.get("original_size", 0) for r in results if r.get("success"))
|
||||
total_comp = sum(r.get("compressed_size", 0) for r in results if r.get("success"))
|
||||
saved = total_orig - total_comp
|
||||
|
||||
if total_orig > 0:
|
||||
pct = (saved / total_orig) * 100
|
||||
msg = (f"压缩完成!\n\n"
|
||||
f"✅ 成功: {success}/{len(results)}\n"
|
||||
f"📊 原始: {self.format_size(total_orig)}\n"
|
||||
f"📦 压缩后: {self.format_size(total_comp)}\n"
|
||||
f"💾 节省: {self.format_size(saved)} ({pct:.1f}%)")
|
||||
else:
|
||||
msg = f"压缩完成!\n✅ 成功: {success}/{len(results)}"
|
||||
|
||||
QMessageBox.information(self, "完成", msg)
|
||||
|
||||
def on_file_saved(self, path):
|
||||
logging.info(f"已保存: {path}")
|
||||
|
||||
def batch_save(self):
|
||||
if not self.processed_results:
|
||||
QMessageBox.warning(self, "提示", "没有可保存的结果,请先压缩")
|
||||
return
|
||||
|
||||
output_dir = QFileDialog.getExistingDirectory(
|
||||
self, "选择保存目录", config.get_output_directory()
|
||||
)
|
||||
if not output_dir:
|
||||
return
|
||||
|
||||
saved = 0
|
||||
for fp, result in self.processed_results.items():
|
||||
try:
|
||||
with open(os.path.join(output_dir, result["output_name"]), 'wb') as f:
|
||||
f.write(result["data"])
|
||||
saved += 1
|
||||
except Exception as e:
|
||||
logging.error(f"保存失败: {e}")
|
||||
|
||||
QMessageBox.information(self, "完成", f"已保存 {saved} 个文件到:\n{output_dir}")
|
||||
|
||||
@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"
|
||||
412
tools/image/convert.py
Normal file
412
tools/image/convert.py
Normal file
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
图片格式转换工具
|
||||
- 支持 JPG/PNG/WEBP/ICO/PDF 互转
|
||||
- 预览转换效果
|
||||
- 批量转换
|
||||
- 进度显示
|
||||
"""
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QFrame, QFileDialog, QMessageBox, QProgressBar,
|
||||
QListWidget, QListWidgetItem, QButtonGroup
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from ui.workspace import BaseWorkspace, UploadArea
|
||||
from ui.image_preview import DualPreviewWidget
|
||||
from core.config import config
|
||||
|
||||
|
||||
class ConvertWorker(QThread):
|
||||
"""转换工作线程"""
|
||||
progress = Signal(int, int)
|
||||
file_processed = Signal(str, bytes, dict, str) # file_path, data, info, output_name
|
||||
finished = Signal(list)
|
||||
|
||||
def __init__(self, files: list, target_format: str, output_dir: str = None):
|
||||
super().__init__()
|
||||
self.files = files
|
||||
self.target_format = target_format.lower()
|
||||
self.output_dir = output_dir
|
||||
self.save_files = output_dir is not None
|
||||
|
||||
def run(self):
|
||||
results = []
|
||||
total = len(self.files)
|
||||
|
||||
for i, file_path in enumerate(self.files):
|
||||
try:
|
||||
result = self.convert_image(file_path)
|
||||
results.append(result)
|
||||
|
||||
if result.get("success") and result.get("data"):
|
||||
self.file_processed.emit(
|
||||
file_path,
|
||||
result["data"],
|
||||
{"size": len(result["data"]), "name": result["output_name"]},
|
||||
result["output_name"]
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"转换失败 {file_path}: {e}")
|
||||
results.append({
|
||||
"file": file_path,
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
self.progress.emit(i + 1, total)
|
||||
|
||||
self.finished.emit(results)
|
||||
|
||||
def convert_image(self, file_path: str) -> dict:
|
||||
"""转换单个图片"""
|
||||
output_name = Path(file_path).stem + f".{self.target_format}"
|
||||
output_buffer = io.BytesIO()
|
||||
|
||||
with Image.open(file_path) as img:
|
||||
# 处理透明通道
|
||||
if self.target_format in ['jpg', 'jpeg', 'pdf']:
|
||||
if img.mode in ('RGBA', 'P', 'LA'):
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 保存到缓冲区
|
||||
if self.target_format == 'ico':
|
||||
sizes = [(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)]
|
||||
img.save(output_buffer, format='ICO', sizes=sizes)
|
||||
elif self.target_format == 'pdf':
|
||||
img.save(output_buffer, 'PDF', resolution=100.0)
|
||||
else:
|
||||
save_format = 'JPEG' if self.target_format in ['jpg', 'jpeg'] else self.target_format.upper()
|
||||
img.save(output_buffer, save_format, quality=95)
|
||||
|
||||
data = output_buffer.getvalue()
|
||||
|
||||
# 如果需要保存
|
||||
output_path = None
|
||||
if self.save_files and self.output_dir:
|
||||
output_path = os.path.join(self.output_dir, output_name)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
return {
|
||||
"file": file_path,
|
||||
"output": output_path,
|
||||
"output_name": output_name,
|
||||
"success": True,
|
||||
"data": data
|
||||
}
|
||||
|
||||
|
||||
class ImageConvertPage(BaseWorkspace):
|
||||
"""图片格式转换页面"""
|
||||
|
||||
FORMATS = ['JPG', 'PNG', 'WEBP', 'ICO', 'PDF']
|
||||
FORMAT_COLORS = {
|
||||
'JPG': '#3b82f6',
|
||||
'PNG': '#22c55e',
|
||||
'WEBP': '#8b5cf6',
|
||||
'ICO': '#f59e0b',
|
||||
'PDF': '#ef4444'
|
||||
}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.files = []
|
||||
self.current_file_index = 0
|
||||
self.processed_results = {}
|
||||
self.selected_format = 'WEBP'
|
||||
self.setup_convert_ui()
|
||||
|
||||
def setup_convert_ui(self):
|
||||
"""设置转换UI"""
|
||||
self.history_btn.hide()
|
||||
self.export_btn.setText("💾 批量保存")
|
||||
self.export_btn.clicked.connect(self.batch_save)
|
||||
|
||||
# 上传区域
|
||||
self.upload_area = UploadArea("图片文件 (*.jpg *.jpeg *.png *.webp *.bmp *.gif)")
|
||||
self.upload_area.files_dropped.connect(self.on_files_added)
|
||||
self.content_layout.addWidget(self.upload_area)
|
||||
|
||||
# 主内容区
|
||||
content_widget = QWidget()
|
||||
content_layout = QHBoxLayout(content_widget)
|
||||
content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
content_layout.setSpacing(24)
|
||||
|
||||
# 左侧 - 预览区
|
||||
self.preview_widget = DualPreviewWidget()
|
||||
self.preview_widget.save_requested.connect(self.on_file_saved)
|
||||
content_layout.addWidget(self.preview_widget, 2)
|
||||
|
||||
# 右侧设置区
|
||||
settings_frame = QFrame()
|
||||
settings_frame.setObjectName("card")
|
||||
settings_frame.setFixedWidth(280)
|
||||
settings_frame.setStyleSheet("""
|
||||
#card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 16px;
|
||||
}
|
||||
""")
|
||||
settings_layout = QVBoxLayout(settings_frame)
|
||||
settings_layout.setContentsMargins(24, 24, 24, 24)
|
||||
settings_layout.setSpacing(16)
|
||||
|
||||
# 标题
|
||||
title = QLabel("🔄 选择目标格式")
|
||||
title.setStyleSheet("color: white; font-weight: 600; font-size: 14px;")
|
||||
settings_layout.addWidget(title)
|
||||
|
||||
# 格式按钮
|
||||
formats_widget = QWidget()
|
||||
formats_layout = QVBoxLayout(formats_widget)
|
||||
formats_layout.setSpacing(8)
|
||||
|
||||
self.format_buttons = {}
|
||||
for fmt in self.FORMATS:
|
||||
btn = QPushButton(fmt)
|
||||
btn.setCheckable(True)
|
||||
btn.setMinimumHeight(40)
|
||||
color = self.FORMAT_COLORS.get(fmt, '#64748b')
|
||||
btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background: rgba({self._hex_to_rgb(color)}, 0.1);
|
||||
border: 2px solid {color};
|
||||
border-radius: 8px;
|
||||
color: {color};
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: rgba({self._hex_to_rgb(color)}, 0.2);
|
||||
}}
|
||||
QPushButton:checked {{
|
||||
background: {color};
|
||||
color: white;
|
||||
}}
|
||||
""")
|
||||
btn.clicked.connect(lambda checked, f=fmt: self.on_format_selected(f))
|
||||
formats_layout.addWidget(btn)
|
||||
self.format_buttons[fmt] = btn
|
||||
|
||||
# 默认选中 WEBP
|
||||
self.format_buttons['WEBP'].setChecked(True)
|
||||
|
||||
settings_layout.addWidget(formats_widget)
|
||||
|
||||
# 文件列表
|
||||
files_header = QHBoxLayout()
|
||||
files_label = QLabel("待转换文件")
|
||||
files_label.setStyleSheet("color: #cbd5e1; font-size: 13px;")
|
||||
files_header.addWidget(files_label)
|
||||
|
||||
self.files_count = QLabel("0")
|
||||
self.files_count.setStyleSheet("color: #fbbf24; font-size: 12px;")
|
||||
files_header.addWidget(self.files_count)
|
||||
files_header.addStretch()
|
||||
|
||||
clear_btn = QPushButton("清空")
|
||||
clear_btn.setObjectName("secondary_btn")
|
||||
clear_btn.setFixedWidth(60)
|
||||
clear_btn.clicked.connect(self.clear_files)
|
||||
files_header.addWidget(clear_btn)
|
||||
|
||||
settings_layout.addLayout(files_header)
|
||||
|
||||
self.files_list = QListWidget()
|
||||
self.files_list.setMaximumHeight(120)
|
||||
self.files_list.itemClicked.connect(self.on_file_clicked)
|
||||
settings_layout.addWidget(self.files_list)
|
||||
|
||||
settings_layout.addStretch()
|
||||
|
||||
# 进度条
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
settings_layout.addWidget(self.progress_bar)
|
||||
|
||||
# 预览按钮
|
||||
self.preview_btn = QPushButton("👁️ 预览效果")
|
||||
self.preview_btn.setObjectName("secondary_btn")
|
||||
self.preview_btn.setMinimumHeight(40)
|
||||
self.preview_btn.clicked.connect(self.preview_current)
|
||||
settings_layout.addWidget(self.preview_btn)
|
||||
|
||||
# 转换按钮
|
||||
self.convert_btn = QPushButton("⚡ 转换全部")
|
||||
self.convert_btn.setObjectName("primary_btn")
|
||||
self.convert_btn.setMinimumSize(150, 45)
|
||||
self.convert_btn.setFont(QFont("Microsoft YaHei", 12, QFont.Weight.Bold))
|
||||
self.convert_btn.clicked.connect(self.start_convert_all)
|
||||
settings_layout.addWidget(self.convert_btn)
|
||||
|
||||
content_layout.addWidget(settings_frame)
|
||||
|
||||
self.content_layout.addWidget(content_widget, 1)
|
||||
|
||||
def on_format_selected(self, fmt: str):
|
||||
"""格式选择"""
|
||||
self.selected_format = fmt
|
||||
for f, btn in self.format_buttons.items():
|
||||
btn.setChecked(f == fmt)
|
||||
|
||||
def on_files_added(self, files: list):
|
||||
"""文件添加"""
|
||||
valid_extensions = ('.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif')
|
||||
for file_path in files:
|
||||
if file_path.lower().endswith(valid_extensions):
|
||||
if file_path not in self.files:
|
||||
self.files.append(file_path)
|
||||
item = QListWidgetItem(f"📷 {Path(file_path).name}")
|
||||
item.setData(Qt.ItemDataRole.UserRole, file_path)
|
||||
self.files_list.addItem(item)
|
||||
|
||||
self.files_count.setText(str(len(self.files)))
|
||||
|
||||
if self.files:
|
||||
self.files_list.setCurrentRow(0)
|
||||
self.preview_widget.set_original(self.files[0])
|
||||
self.current_file_index = 0
|
||||
|
||||
logging.info(f"添加了 {len(files)} 个文件用于转换")
|
||||
|
||||
def on_file_clicked(self, item: QListWidgetItem):
|
||||
"""文件点击"""
|
||||
file_path = item.data(Qt.ItemDataRole.UserRole)
|
||||
self.current_file_index = self.files.index(file_path)
|
||||
self.preview_widget.set_original(file_path)
|
||||
|
||||
if file_path in self.processed_results:
|
||||
result = self.processed_results[file_path]
|
||||
self.preview_widget.set_result(
|
||||
result["data"],
|
||||
{"size": len(result["data"]), "name": result["output_name"]},
|
||||
result["output_name"]
|
||||
)
|
||||
|
||||
def clear_files(self):
|
||||
"""清空文件"""
|
||||
self.files.clear()
|
||||
self.files_list.clear()
|
||||
self.files_count.setText("0")
|
||||
self.processed_results.clear()
|
||||
self.preview_widget.clear()
|
||||
|
||||
def preview_current(self):
|
||||
"""预览当前文件"""
|
||||
if not self.files:
|
||||
QMessageBox.warning(self, "提示", "请先添加要转换的图片文件")
|
||||
return
|
||||
|
||||
file_path = self.files[self.current_file_index]
|
||||
|
||||
self.preview_btn.setEnabled(False)
|
||||
self.preview_btn.setText("处理中...")
|
||||
|
||||
self.worker = ConvertWorker([file_path], self.selected_format, None)
|
||||
self.worker.file_processed.connect(self.on_preview_ready)
|
||||
self.worker.finished.connect(lambda: self.preview_btn.setEnabled(True))
|
||||
self.worker.finished.connect(lambda: self.preview_btn.setText("👁️ 预览效果"))
|
||||
self.worker.start()
|
||||
|
||||
def on_preview_ready(self, file_path: str, data: bytes, info: dict, output_name: str):
|
||||
"""预览完成"""
|
||||
self.preview_widget.set_result(data, info, output_name, show_size_compare=False)
|
||||
self.processed_results[file_path] = {
|
||||
"data": data,
|
||||
"output_name": output_name
|
||||
}
|
||||
|
||||
def start_convert_all(self):
|
||||
"""转换所有文件"""
|
||||
if not self.files:
|
||||
QMessageBox.warning(self, "提示", "请先添加要转换的图片文件")
|
||||
return
|
||||
|
||||
self.convert_btn.setEnabled(False)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
|
||||
self.worker = ConvertWorker(self.files, self.selected_format, None)
|
||||
self.worker.progress.connect(self.on_progress)
|
||||
self.worker.file_processed.connect(self.on_file_processed)
|
||||
self.worker.finished.connect(self.on_convert_finished)
|
||||
self.worker.start()
|
||||
|
||||
logging.info(f"开始转换 {len(self.files)} 个文件为 {self.selected_format}")
|
||||
|
||||
def on_progress(self, current: int, total: int):
|
||||
"""进度更新"""
|
||||
self.progress_bar.setValue(int(current / total * 100))
|
||||
|
||||
def on_file_processed(self, file_path: str, data: bytes, info: dict, output_name: str):
|
||||
"""文件处理完成"""
|
||||
self.processed_results[file_path] = {
|
||||
"data": data,
|
||||
"output_name": output_name
|
||||
}
|
||||
|
||||
if self.files.index(file_path) == self.current_file_index:
|
||||
self.preview_widget.set_result(data, info, output_name, show_size_compare=False)
|
||||
|
||||
def on_convert_finished(self, results: list):
|
||||
"""转换完成"""
|
||||
self.convert_btn.setEnabled(True)
|
||||
self.progress_bar.setVisible(False)
|
||||
|
||||
success_count = sum(1 for r in results if r.get("success"))
|
||||
|
||||
msg = f"转换完成!\n\n✅ 成功: {success_count}/{len(results)}\n\n请点击「批量保存」或在预览中单独保存"
|
||||
QMessageBox.information(self, "转换结果", msg)
|
||||
logging.info(f"转换完成: 成功 {success_count}/{len(results)}")
|
||||
|
||||
def on_file_saved(self, save_path):
|
||||
"""文件保存"""
|
||||
logging.info(f"文件已保存: {save_path}")
|
||||
|
||||
def batch_save(self):
|
||||
"""批量保存"""
|
||||
if not self.processed_results:
|
||||
QMessageBox.warning(self, "提示", "没有可保存的处理结果")
|
||||
return
|
||||
|
||||
default_dir = config.get_output_directory()
|
||||
output_dir = QFileDialog.getExistingDirectory(self, "选择保存目录", default_dir)
|
||||
|
||||
if not output_dir:
|
||||
return
|
||||
|
||||
saved_count = 0
|
||||
for file_path, result in self.processed_results.items():
|
||||
try:
|
||||
output_path = os.path.join(output_dir, result["output_name"])
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(result["data"])
|
||||
saved_count += 1
|
||||
except Exception as e:
|
||||
logging.error(f"保存失败 {file_path}: {e}")
|
||||
|
||||
QMessageBox.information(
|
||||
self, "保存完成",
|
||||
f"已保存 {saved_count}/{len(self.processed_results)} 个文件到:\n{output_dir}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _hex_to_rgb(hex_color: str) -> str:
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
return f"{r}, {g}, {b}"
|
||||
579
tools/image/watermark.py
Normal file
579
tools/image/watermark.py
Normal file
@@ -0,0 +1,579 @@
|
||||
"""
|
||||
图片加水印工具
|
||||
- 支持文字水印和图片水印
|
||||
- 可调整位置、透明度、大小
|
||||
- 预览功能
|
||||
- 批量处理
|
||||
"""
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QSlider, QFrame, QFileDialog, QMessageBox, QProgressBar,
|
||||
QListWidget, QListWidgetItem, QLineEdit, QComboBox,
|
||||
QTabWidget, QSpinBox, QColorDialog
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QFont, QColor
|
||||
|
||||
from ui.workspace import BaseWorkspace, UploadArea
|
||||
from ui.image_preview import DualPreviewWidget
|
||||
from core.config import config
|
||||
|
||||
|
||||
class WatermarkWorker(QThread):
|
||||
"""水印工作线程"""
|
||||
progress = Signal(int, int)
|
||||
file_processed = Signal(str, bytes, dict, str) # file_path, data, info, output_name
|
||||
finished = Signal(list)
|
||||
|
||||
def __init__(self, files: list, watermark_config: dict, output_dir: str = None):
|
||||
super().__init__()
|
||||
self.files = files
|
||||
self.config = watermark_config
|
||||
self.output_dir = output_dir
|
||||
self.save_files = output_dir is not None
|
||||
|
||||
def run(self):
|
||||
results = []
|
||||
total = len(self.files)
|
||||
|
||||
for i, file_path in enumerate(self.files):
|
||||
try:
|
||||
result = self.add_watermark(file_path)
|
||||
results.append(result)
|
||||
|
||||
if result.get("success") and result.get("data"):
|
||||
self.file_processed.emit(
|
||||
file_path,
|
||||
result["data"],
|
||||
{"size": len(result["data"]), "name": result["output_name"]},
|
||||
result["output_name"]
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"添加水印失败 {file_path}: {e}")
|
||||
results.append({
|
||||
"file": file_path,
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
self.progress.emit(i + 1, total)
|
||||
|
||||
self.finished.emit(results)
|
||||
|
||||
def add_watermark(self, file_path: str) -> dict:
|
||||
"""添加水印"""
|
||||
ext = Path(file_path).suffix.lower()
|
||||
output_name = Path(file_path).stem + "_watermarked" + ext
|
||||
output_buffer = io.BytesIO()
|
||||
|
||||
with Image.open(file_path) as img:
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
watermark_layer = Image.new('RGBA', img.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(watermark_layer)
|
||||
|
||||
if self.config['type'] == 'text':
|
||||
self.add_text_watermark(draw, img.size)
|
||||
else:
|
||||
self.add_image_watermark(watermark_layer, img.size)
|
||||
|
||||
result = Image.alpha_composite(img, watermark_layer)
|
||||
|
||||
# 保存
|
||||
if ext in ['.jpg', '.jpeg']:
|
||||
result = result.convert('RGB')
|
||||
result.save(output_buffer, 'JPEG', quality=95)
|
||||
elif ext == '.png':
|
||||
result.save(output_buffer, 'PNG')
|
||||
else:
|
||||
result = result.convert('RGB')
|
||||
result.save(output_buffer, 'JPEG', quality=95)
|
||||
output_name = Path(file_path).stem + "_watermarked.jpg"
|
||||
|
||||
data = output_buffer.getvalue()
|
||||
|
||||
# 如果需要保存
|
||||
output_path = None
|
||||
if self.save_files and self.output_dir:
|
||||
output_path = os.path.join(self.output_dir, output_name)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
return {
|
||||
"file": file_path,
|
||||
"output": output_path,
|
||||
"output_name": output_name,
|
||||
"success": True,
|
||||
"data": data
|
||||
}
|
||||
|
||||
def add_text_watermark(self, draw: ImageDraw, img_size: tuple):
|
||||
"""添加文字水印"""
|
||||
text = self.config.get('text', 'Watermark')
|
||||
opacity = int(self.config.get('opacity', 50) * 2.55)
|
||||
font_size = self.config.get('font_size', 48) # 默认更大的字体
|
||||
color = self.config.get('color', (255, 255, 255))
|
||||
position = self.config.get('position', 'center')
|
||||
|
||||
# 尝试使用支持中文的字体
|
||||
font = None
|
||||
# Windows 中文字体列表
|
||||
chinese_fonts = [
|
||||
"C:/Windows/Fonts/msyh.ttc", # 微软雅黑
|
||||
"C:/Windows/Fonts/simhei.ttf", # 黑体
|
||||
"C:/Windows/Fonts/simsun.ttc", # 宋体
|
||||
"C:/Windows/Fonts/simkai.ttf", # 楷体
|
||||
"msyh.ttc",
|
||||
"simhei.ttf",
|
||||
"arial.ttf",
|
||||
]
|
||||
|
||||
for font_path in chinese_fonts:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
positions = {
|
||||
'top-left': (20, 20),
|
||||
'top-right': (img_size[0] - text_width - 20, 20),
|
||||
'bottom-left': (20, img_size[1] - text_height - 20),
|
||||
'bottom-right': (img_size[0] - text_width - 20, img_size[1] - text_height - 20),
|
||||
'center': ((img_size[0] - text_width) // 2, (img_size[1] - text_height) // 2)
|
||||
}
|
||||
|
||||
x, y = positions.get(position, positions['center'])
|
||||
draw.text((x, y), text, font=font, fill=(*color, opacity))
|
||||
|
||||
def add_image_watermark(self, layer: Image, img_size: tuple):
|
||||
"""添加图片水印"""
|
||||
watermark_path = self.config.get('image_path')
|
||||
if not watermark_path or not os.path.exists(watermark_path):
|
||||
return
|
||||
|
||||
opacity = self.config.get('opacity', 50) / 100
|
||||
scale = self.config.get('scale', 20) / 100
|
||||
position = self.config.get('position', 'center')
|
||||
|
||||
with Image.open(watermark_path) as watermark:
|
||||
if watermark.mode != 'RGBA':
|
||||
watermark = watermark.convert('RGBA')
|
||||
|
||||
new_width = int(img_size[0] * scale)
|
||||
ratio = new_width / watermark.width
|
||||
new_height = int(watermark.height * ratio)
|
||||
watermark = watermark.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
alpha = watermark.split()[3]
|
||||
alpha = alpha.point(lambda p: int(p * opacity))
|
||||
watermark.putalpha(alpha)
|
||||
|
||||
positions = {
|
||||
'top-left': (20, 20),
|
||||
'top-right': (img_size[0] - new_width - 20, 20),
|
||||
'bottom-left': (20, img_size[1] - new_height - 20),
|
||||
'bottom-right': (img_size[0] - new_width - 20, img_size[1] - new_height - 20),
|
||||
'center': ((img_size[0] - new_width) // 2, (img_size[1] - new_height) // 2)
|
||||
}
|
||||
|
||||
x, y = positions.get(position, positions['center'])
|
||||
layer.paste(watermark, (x, y), watermark)
|
||||
|
||||
|
||||
class ImageWatermarkPage(BaseWorkspace):
|
||||
"""图片加水印页面"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.files = []
|
||||
self.current_file_index = 0
|
||||
self.processed_results = {}
|
||||
self.watermark_color = (255, 255, 255)
|
||||
self.watermark_image_path = None
|
||||
self.setup_watermark_ui()
|
||||
|
||||
def setup_watermark_ui(self):
|
||||
"""设置水印UI"""
|
||||
self.history_btn.hide()
|
||||
self.export_btn.setText("💾 批量保存")
|
||||
self.export_btn.clicked.connect(self.batch_save)
|
||||
|
||||
# 上传区域
|
||||
self.upload_area = UploadArea("图片文件 (*.jpg *.jpeg *.png *.webp)")
|
||||
self.upload_area.files_dropped.connect(self.on_files_added)
|
||||
self.content_layout.addWidget(self.upload_area)
|
||||
|
||||
# 主内容区
|
||||
content_widget = QWidget()
|
||||
content_layout = QHBoxLayout(content_widget)
|
||||
content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
content_layout.setSpacing(24)
|
||||
|
||||
# 左侧 - 预览区
|
||||
self.preview_widget = DualPreviewWidget()
|
||||
self.preview_widget.save_requested.connect(self.on_file_saved)
|
||||
content_layout.addWidget(self.preview_widget, 2)
|
||||
|
||||
# 右侧设置区
|
||||
settings_frame = QFrame()
|
||||
settings_frame.setObjectName("card")
|
||||
settings_frame.setFixedWidth(300)
|
||||
settings_frame.setStyleSheet("""
|
||||
#card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 16px;
|
||||
}
|
||||
""")
|
||||
settings_layout = QVBoxLayout(settings_frame)
|
||||
settings_layout.setContentsMargins(20, 20, 20, 20)
|
||||
settings_layout.setSpacing(16)
|
||||
|
||||
# 标签页
|
||||
self.tab_widget = QTabWidget()
|
||||
self.tab_widget.setStyleSheet("""
|
||||
QTabWidget::pane { border: none; background: transparent; }
|
||||
QTabBar::tab {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
QTabBar::tab:selected { color: #fbbf24; border-bottom: 2px solid #fbbf24; }
|
||||
""")
|
||||
|
||||
# 文字水印
|
||||
text_tab = QWidget()
|
||||
text_layout = QVBoxLayout(text_tab)
|
||||
text_layout.setSpacing(12)
|
||||
|
||||
text_input_layout = QHBoxLayout()
|
||||
text_input_layout.addWidget(QLabel("水印文字:"))
|
||||
self.text_input = QLineEdit("© 奶酪云工具箱")
|
||||
text_input_layout.addWidget(self.text_input, 1)
|
||||
text_layout.addLayout(text_input_layout)
|
||||
|
||||
font_layout = QHBoxLayout()
|
||||
font_layout.addWidget(QLabel("字体大小:"))
|
||||
self.font_size_spin = QSpinBox()
|
||||
self.font_size_spin.setRange(24, 300)
|
||||
self.font_size_spin.setValue(72) # 默认更大的字体
|
||||
font_layout.addWidget(self.font_size_spin)
|
||||
font_layout.addStretch()
|
||||
|
||||
font_layout.addWidget(QLabel("颜色:"))
|
||||
self.color_btn = QPushButton()
|
||||
self.color_btn.setFixedSize(40, 30)
|
||||
self.color_btn.setStyleSheet("background: white; border-radius: 4px;")
|
||||
self.color_btn.clicked.connect(self.choose_color)
|
||||
font_layout.addWidget(self.color_btn)
|
||||
text_layout.addLayout(font_layout)
|
||||
|
||||
self.tab_widget.addTab(text_tab, "📝 文字水印")
|
||||
|
||||
# 图片水印
|
||||
image_tab = QWidget()
|
||||
image_layout = QVBoxLayout(image_tab)
|
||||
image_layout.setSpacing(12)
|
||||
|
||||
img_select_layout = QHBoxLayout()
|
||||
img_select_layout.addWidget(QLabel("水印图片:"))
|
||||
self.watermark_path_label = QLabel("未选择")
|
||||
self.watermark_path_label.setStyleSheet("color: #64748b;")
|
||||
img_select_layout.addWidget(self.watermark_path_label, 1)
|
||||
|
||||
select_img_btn = QPushButton("选择")
|
||||
select_img_btn.setObjectName("secondary_btn")
|
||||
select_img_btn.clicked.connect(self.select_watermark_image)
|
||||
img_select_layout.addWidget(select_img_btn)
|
||||
image_layout.addLayout(img_select_layout)
|
||||
|
||||
scale_layout = QHBoxLayout()
|
||||
scale_layout.addWidget(QLabel("缩放:"))
|
||||
self.scale_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.scale_slider.setRange(5, 50)
|
||||
self.scale_slider.setValue(20)
|
||||
scale_layout.addWidget(self.scale_slider, 1)
|
||||
self.scale_value = QLabel("20%")
|
||||
self.scale_slider.valueChanged.connect(lambda v: self.scale_value.setText(f"{v}%"))
|
||||
scale_layout.addWidget(self.scale_value)
|
||||
image_layout.addLayout(scale_layout)
|
||||
|
||||
self.tab_widget.addTab(image_tab, "🖼️ 图片水印")
|
||||
|
||||
settings_layout.addWidget(self.tab_widget)
|
||||
|
||||
# 通用设置
|
||||
common_frame = QFrame()
|
||||
common_frame.setStyleSheet("background: rgba(15, 23, 42, 0.5); border-radius: 8px; padding: 8px;")
|
||||
common_layout = QVBoxLayout(common_frame)
|
||||
common_layout.setSpacing(8)
|
||||
|
||||
opacity_layout = QHBoxLayout()
|
||||
opacity_layout.addWidget(QLabel("透明度:"))
|
||||
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.opacity_slider.setRange(10, 100)
|
||||
self.opacity_slider.setValue(50)
|
||||
opacity_layout.addWidget(self.opacity_slider, 1)
|
||||
self.opacity_value = QLabel("50%")
|
||||
self.opacity_slider.valueChanged.connect(lambda v: self.opacity_value.setText(f"{v}%"))
|
||||
opacity_layout.addWidget(self.opacity_value)
|
||||
common_layout.addLayout(opacity_layout)
|
||||
|
||||
pos_layout = QHBoxLayout()
|
||||
pos_layout.addWidget(QLabel("位置:"))
|
||||
self.position_combo = QComboBox()
|
||||
positions = [("左上角", "top-left"), ("右上角", "top-right"),
|
||||
("左下角", "bottom-left"), ("右下角", "bottom-right"), ("居中", "center")]
|
||||
for text, value in positions:
|
||||
self.position_combo.addItem(text, value)
|
||||
self.position_combo.setCurrentIndex(4)
|
||||
pos_layout.addWidget(self.position_combo)
|
||||
pos_layout.addStretch()
|
||||
common_layout.addLayout(pos_layout)
|
||||
|
||||
settings_layout.addWidget(common_frame)
|
||||
|
||||
# 文件列表
|
||||
files_header = QHBoxLayout()
|
||||
files_label = QLabel("📁 待处理:")
|
||||
files_header.addWidget(files_label)
|
||||
self.count_label = QLabel("0")
|
||||
self.count_label.setStyleSheet("color: #fbbf24;")
|
||||
files_header.addWidget(self.count_label)
|
||||
files_header.addStretch()
|
||||
|
||||
clear_btn = QPushButton("清空")
|
||||
clear_btn.setObjectName("secondary_btn")
|
||||
clear_btn.clicked.connect(self.clear_files)
|
||||
files_header.addWidget(clear_btn)
|
||||
settings_layout.addLayout(files_header)
|
||||
|
||||
self.files_list = QListWidget()
|
||||
self.files_list.setMaximumHeight(80)
|
||||
self.files_list.itemClicked.connect(self.on_file_clicked)
|
||||
settings_layout.addWidget(self.files_list)
|
||||
|
||||
settings_layout.addStretch()
|
||||
|
||||
# 进度条
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
settings_layout.addWidget(self.progress_bar)
|
||||
|
||||
# 预览按钮
|
||||
self.preview_btn = QPushButton("👁️ 预览效果")
|
||||
self.preview_btn.setObjectName("secondary_btn")
|
||||
self.preview_btn.setMinimumHeight(40)
|
||||
self.preview_btn.clicked.connect(self.preview_current)
|
||||
settings_layout.addWidget(self.preview_btn)
|
||||
|
||||
# 开始按钮
|
||||
self.start_btn = QPushButton("💧 添加水印")
|
||||
self.start_btn.setObjectName("primary_btn")
|
||||
self.start_btn.setMinimumSize(150, 45)
|
||||
self.start_btn.setFont(QFont("Microsoft YaHei", 12, QFont.Weight.Bold))
|
||||
self.start_btn.clicked.connect(self.start_watermark_all)
|
||||
settings_layout.addWidget(self.start_btn)
|
||||
|
||||
content_layout.addWidget(settings_frame)
|
||||
|
||||
self.content_layout.addWidget(content_widget, 1)
|
||||
|
||||
def on_files_added(self, files: list):
|
||||
"""文件添加"""
|
||||
for file_path in files:
|
||||
if file_path.lower().endswith(('.jpg', '.jpeg', '.png', '.webp')):
|
||||
if file_path not in self.files:
|
||||
self.files.append(file_path)
|
||||
self.files_list.addItem(f"📷 {Path(file_path).name}")
|
||||
|
||||
self.count_label.setText(str(len(self.files)))
|
||||
|
||||
if self.files:
|
||||
self.files_list.setCurrentRow(0)
|
||||
self.preview_widget.set_original(self.files[0])
|
||||
self.current_file_index = 0
|
||||
|
||||
def on_file_clicked(self, item):
|
||||
"""文件点击"""
|
||||
row = self.files_list.currentRow()
|
||||
if row >= 0 and row < len(self.files):
|
||||
self.current_file_index = row
|
||||
file_path = self.files[row]
|
||||
self.preview_widget.set_original(file_path)
|
||||
|
||||
if file_path in self.processed_results:
|
||||
result = self.processed_results[file_path]
|
||||
self.preview_widget.set_result(
|
||||
result["data"],
|
||||
{"size": len(result["data"]), "name": result["output_name"]},
|
||||
result["output_name"]
|
||||
)
|
||||
|
||||
def clear_files(self):
|
||||
"""清空文件"""
|
||||
self.files.clear()
|
||||
self.files_list.clear()
|
||||
self.count_label.setText("0")
|
||||
self.processed_results.clear()
|
||||
self.preview_widget.clear()
|
||||
|
||||
def choose_color(self):
|
||||
"""选择颜色"""
|
||||
color = QColorDialog.getColor(QColor(*self.watermark_color), self)
|
||||
if color.isValid():
|
||||
self.watermark_color = (color.red(), color.green(), color.blue())
|
||||
self.color_btn.setStyleSheet(f"background: {color.name()}; border-radius: 4px;")
|
||||
|
||||
def select_watermark_image(self):
|
||||
"""选择水印图片"""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "选择水印图片", "", "图片文件 (*.png *.jpg *.jpeg)"
|
||||
)
|
||||
if file_path:
|
||||
self.watermark_image_path = file_path
|
||||
self.watermark_path_label.setText(Path(file_path).name)
|
||||
|
||||
def get_watermark_config(self) -> dict:
|
||||
"""获取水印配置"""
|
||||
is_text = self.tab_widget.currentIndex() == 0
|
||||
config = {
|
||||
'type': 'text' if is_text else 'image',
|
||||
'opacity': self.opacity_slider.value(),
|
||||
'position': self.position_combo.currentData()
|
||||
}
|
||||
|
||||
if is_text:
|
||||
config['text'] = self.text_input.text() or 'Watermark'
|
||||
config['font_size'] = self.font_size_spin.value()
|
||||
config['color'] = self.watermark_color
|
||||
else:
|
||||
config['image_path'] = self.watermark_image_path
|
||||
config['scale'] = self.scale_slider.value()
|
||||
|
||||
return config
|
||||
|
||||
def preview_current(self):
|
||||
"""预览当前文件"""
|
||||
if not self.files:
|
||||
QMessageBox.warning(self, "提示", "请先添加要处理的图片文件")
|
||||
return
|
||||
|
||||
watermark_config = self.get_watermark_config()
|
||||
if watermark_config['type'] == 'image' and not self.watermark_image_path:
|
||||
QMessageBox.warning(self, "提示", "请先选择水印图片")
|
||||
return
|
||||
|
||||
file_path = self.files[self.current_file_index]
|
||||
|
||||
self.preview_btn.setEnabled(False)
|
||||
self.preview_btn.setText("处理中...")
|
||||
|
||||
self.worker = WatermarkWorker([file_path], watermark_config, None)
|
||||
self.worker.file_processed.connect(self.on_preview_ready)
|
||||
self.worker.finished.connect(lambda: self.preview_btn.setEnabled(True))
|
||||
self.worker.finished.connect(lambda: self.preview_btn.setText("👁️ 预览效果"))
|
||||
self.worker.start()
|
||||
|
||||
def on_preview_ready(self, file_path: str, data: bytes, info: dict, output_name: str):
|
||||
"""预览完成"""
|
||||
self.preview_widget.set_result(data, info, output_name, show_size_compare=False)
|
||||
self.processed_results[file_path] = {
|
||||
"data": data,
|
||||
"output_name": output_name
|
||||
}
|
||||
|
||||
def start_watermark_all(self):
|
||||
"""处理所有文件"""
|
||||
if not self.files:
|
||||
QMessageBox.warning(self, "提示", "请先添加要处理的图片文件")
|
||||
return
|
||||
|
||||
watermark_config = self.get_watermark_config()
|
||||
if watermark_config['type'] == 'image' and not self.watermark_image_path:
|
||||
QMessageBox.warning(self, "提示", "请先选择水印图片")
|
||||
return
|
||||
|
||||
self.start_btn.setEnabled(False)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
|
||||
self.worker = WatermarkWorker(self.files, watermark_config, None)
|
||||
self.worker.progress.connect(self.on_progress)
|
||||
self.worker.file_processed.connect(self.on_file_processed)
|
||||
self.worker.finished.connect(self.on_finished)
|
||||
self.worker.start()
|
||||
|
||||
logging.info(f"开始添加水印, 文件数: {len(self.files)}")
|
||||
|
||||
def on_progress(self, current: int, total: int):
|
||||
"""进度更新"""
|
||||
self.progress_bar.setValue(int(current / total * 100))
|
||||
|
||||
def on_file_processed(self, file_path: str, data: bytes, info: dict, output_name: str):
|
||||
"""文件处理完成"""
|
||||
self.processed_results[file_path] = {
|
||||
"data": data,
|
||||
"output_name": output_name
|
||||
}
|
||||
|
||||
if self.files.index(file_path) == self.current_file_index:
|
||||
self.preview_widget.set_result(data, info, output_name, show_size_compare=False)
|
||||
|
||||
def on_finished(self, results: list):
|
||||
"""处理完成"""
|
||||
self.start_btn.setEnabled(True)
|
||||
self.progress_bar.setVisible(False)
|
||||
|
||||
success_count = sum(1 for r in results if r.get("success"))
|
||||
QMessageBox.information(
|
||||
self, "完成",
|
||||
f"水印添加完成!\n\n✅ 成功: {success_count}/{len(results)}\n\n请点击「批量保存」或在预览中单独保存"
|
||||
)
|
||||
logging.info(f"水印添加完成: 成功 {success_count}/{len(results)}")
|
||||
|
||||
def on_file_saved(self, save_path):
|
||||
"""文件保存"""
|
||||
logging.info(f"文件已保存: {save_path}")
|
||||
|
||||
def batch_save(self):
|
||||
"""批量保存"""
|
||||
if not self.processed_results:
|
||||
QMessageBox.warning(self, "提示", "没有可保存的处理结果")
|
||||
return
|
||||
|
||||
default_dir = config.get_output_directory()
|
||||
output_dir = QFileDialog.getExistingDirectory(self, "选择保存目录", default_dir)
|
||||
|
||||
if not output_dir:
|
||||
return
|
||||
|
||||
saved_count = 0
|
||||
for file_path, result in self.processed_results.items():
|
||||
try:
|
||||
output_path = os.path.join(output_dir, result["output_name"])
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(result["data"])
|
||||
saved_count += 1
|
||||
except Exception as e:
|
||||
logging.error(f"保存失败 {file_path}: {e}")
|
||||
|
||||
QMessageBox.information(
|
||||
self, "保存完成",
|
||||
f"已保存 {saved_count}/{len(self.processed_results)} 个文件到:\n{output_dir}"
|
||||
)
|
||||
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