413 lines
15 KiB
Python
413 lines
15 KiB
Python
|
|
"""
|
||
|
|
图片格式转换工具
|
||
|
|
- 支持 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}"
|