初始化

This commit is contained in:
2025-12-11 09:47:00 +08:00
commit 99933d62f8
65 changed files with 121176 additions and 0 deletions

15
tools/image/__init__.py Normal file
View 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
View 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
View 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
View 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}"
)