PyQt5 实战:批量图片添加水印工具(带右侧实时预览)(附代码及下载链接)

目录

    • [PyQt5 实战:批量图片添加水印工具(带右侧实时预览)](#PyQt5 实战:批量图片添加水印工具(带右侧实时预览))
    • [1. 需求与界面目标](#1. 需求与界面目标)
      • [1.1 效果截图](#1.1 效果截图)
    • [2. 技术选型与依赖](#2. 技术选型与依赖)
      • [2.1 GUI:PyQt5](#2.1 GUI:PyQt5)
      • [2.2 图片处理:QImage + QPainter](#2.2 图片处理:QImage + QPainter)
    • [3. 工程结构(单文件也能清晰)](#3. 工程结构(单文件也能清晰))
    • [4. 界面实现:左配右预览](#4. 界面实现:左配右预览)
      • [4.1 左侧参数配置区](#4.1 左侧参数配置区)
      • [4.2 右侧预览区](#4.2 右侧预览区)
    • [5. 关键交互:参数变化即刷新预览](#5. 关键交互:参数变化即刷新预览)
    • [6. 水印渲染核心:QPainter 怎么画文字水印](#6. 水印渲染核心:QPainter 怎么画文字水印)
      • [6.1 单个水印:位置 + 旋转](#6.1 单个水印:位置 + 旋转)
      • [6.2 全屏平铺水印:平铺范围与密度控制](#6.2 全屏平铺水印:平铺范围与密度控制)
    • [7. 处理单图与批量处理:文件选择与输出规则](#7. 处理单图与批量处理:文件选择与输出规则)
      • [7.1 处理单图](#7.1 处理单图)
      • [7.2 批量处理文件夹](#7.2 批量处理文件夹)
    • [8. 如何运行](#8. 如何运行)
      • [8.1 安装依赖](#8.1 安装依赖)
      • [8.2 启动程序](#8.2 启动程序)
    • [9. 常见问题(FAQ)](#9. 常见问题(FAQ))
      • [9.1 为什么有些图片保存失败?](#9.1 为什么有些图片保存失败?)
      • [9.2 透明度为什么感觉不明显?](#9.2 透明度为什么感觉不明显?)
      • [9.3 全屏水印密度怎么控制?](#9.3 全屏水印密度怎么控制?)
    • [10. 可扩展方向](#10. 可扩展方向)
    • [11. 打包成可执行程序(Windows)](#11. 打包成可执行程序(Windows))
      • [11.1 安装 PyInstaller](#11.1 安装 PyInstaller)
      • [11.2 一键打包(无控制台窗口)](#11.2 一键打包(无控制台窗口))
      • [11.3 常见打包问题](#11.3 常见打包问题)
    • [12. 关键代码入口](#12. 关键代码入口)
    • 完整代码

专栏导读

🌸 欢迎来到Python办公自动化专栏---Python处理办公问题,解放您的双手
🏳️‍🌈 个人博客主页:请点击------> 个人的博客主页 求收藏
🏳️‍🌈 Github主页:请点击------> Github主页 求Star⭐
🏳️‍🌈 知乎主页:请点击------> 知乎主页 求关注
🏳️‍🌈 CSDN博客主页:请点击------> CSDN的博客主页 求关注
👍 该系列文章专栏:请点击------>Python办公自动化专栏 求订阅
🕷 此外还有爬虫专栏:请点击------>Python爬虫基础专栏 求订阅
📕 此外还有python基础专栏:请点击------>Python基础学习专栏 求订阅
文章作者技术和水平有限,如果文中出现错误,希望大家能指正🙏
❤️ 欢迎各位佬关注! ❤️

PyQt5 实战:批量图片添加水印工具(带右侧实时预览)

这篇文章完整记录一个「批量图片添加水印」桌面工具的研发过程:左侧配置水印参数,右侧实时预览效果,支持单张处理与文件夹批量处理,适合用作 PyQt5 入门到实战的小项目。

项目代码:main.py


Gitcode下载链接:点我进行下载

Github下载链接:点我进行下载

Exe资源下载链接: 点我进行下载

1. 需求与界面目标

目标界面(和你提供的截图一致的交互形态):

  • 左侧「水印参数配置」
    • 水印文字(可编辑)
    • 字体大小(像素)
    • 透明度(0-100)
    • 文字颜色(弹窗取色)
    • 水印位置(左上/右上/左下/右下/居中)
    • 全屏水印模式(忽略位置,按间距平铺)
    • 水印旋转角度
    • 水平/垂直间距
    • 行数/列数(可选,辅助控制密度)
    • 两个按钮:处理单个图片 / 批量处理文件夹
  • 右侧「预览」
    • 选择预览图片后,参数变化即时刷新预览
  • 批量处理结束时弹出提示:成功处理图片数量

1.1 效果截图

你可以把截图放到项目目录下(例如 assets/ui.png),然后在博客里引用:

markdown 复制代码
![批量图片加水印工具-界面预览](assets/ui.png)

2. 技术选型与依赖

2.1 GUI:PyQt5

使用 PyQt5 的原因:

  • 组件完善(输入框、下拉框、取色对话框、文件选择对话框等一应俱全)
  • QPainter 绘制文字水印非常顺手
  • 跨平台(Windows/macOS/Linux)

2.2 图片处理:QImage + QPainter

核心思路:把原图读入为 QImage,创建一个同尺寸画布,在画布上先绘制原图,再绘制水印文字,最后保存到目标路径。


3. 工程结构(单文件也能清晰)

这个项目以单文件实现为主,代码结构仍然保持「可扩展」:

  • WatermarkSettings:水印配置数据模型(dataclass)
  • MainWindow:界面与交互
    • _build_ui():搭界面
    • _wire_signals():绑定信号,实现"改参数即刷新预览"
    • _apply_watermark():把水印绘制到图片上
    • _process_single_image():单图处理
    • _process_folder():文件夹批量处理

4. 界面实现:左配右预览

4.1 左侧参数配置区

左侧用一个 QGroupBox("水印参数配置") 包起来,内部用 QVBoxLayout 纵向堆叠每一行配置项,每一行再用 QHBoxLayout 实现"标签 + 控件"的排列。

典型控件选择:

  • 文本:QLineEdit
  • 数值:QSpinBox(整数)、QDoubleSpinBox(旋转角度)
  • 下拉:QComboBox
  • 开关:QCheckBox
  • 取色:QColorDialog

对应实现位置:

  • MainWindow._build_ui():创建控件并组装布局

4.2 右侧预览区

右侧同样用 QGroupBox("预览") 包起来,核心是一个 QLabel 作为画布:

  • 通过 QPixmap.fromImage()QImage 转为 QPixmap
  • 使用 scaled(..., Qt.KeepAspectRatio, Qt.SmoothTransformation) 自适应缩放

预览逻辑在:

  • MainWindow._update_preview()
  • 并在窗口 resizeEvent 里触发刷新,保证拖动窗口大小时预览不变形

5. 关键交互:参数变化即刷新预览

思路很简单:把所有"会影响预览"的控件信号都绑定到 _update_preview()

在代码里使用了一个小技巧:遍历控件列表,按控件可能拥有的信号类型去连接:

  • 文本框:textChanged
  • SpinBox:valueChanged
  • ComboBox:currentTextChanged
  • CheckBox:toggled

对应实现位置:

  • MainWindow._wire_signals()

这样新增控件也方便:只要把控件加进列表,就能自动获得"联动预览"能力。


6. 水印渲染核心:QPainter 怎么画文字水印

水印绘制主要分两类:

  1. 单个水印(按位置绘制一次)
  2. 全屏水印(按间距平铺绘制多次)

它们都共享同一段"准备画笔"的逻辑:

  • 转成 QImage.Format_ARGB32,确保支持透明度混合
  • painter.setOpacity(opacity/100) 设置整体透明度
  • 设置字体像素大小 font.setPixelSize(font_size)
  • QPen(QColor) 设置文字颜色

对应实现位置:

  • MainWindow._apply_watermark()

6.1 单个水印:位置 + 旋转

单个水印的关键点:

  • 先用 QFontMetricsF 测量文字矩形(用于右下等位置的对齐)
  • 通过 painter.translate(...) 把坐标系移动到文字中心
  • painter.rotate(angle) 旋转
  • 在"旋转后的坐标系"里绘制文字

对应实现位置:

  • MainWindow._draw_single()

6.2 全屏平铺水印:平铺范围与密度控制

平铺水印的关键点:

  • 把坐标系移动到图片中心再旋转,这样平铺更自然
  • 使用 spacing_x / spacing_y 控制密度
  • 可选 rows / cols:当用户指定行列数时,用图片宽高反推间距
  • 平铺范围用 [-w, w][-h, h],保证旋转后边角仍覆盖

对应实现位置:

  • MainWindow._draw_tiled()

7. 处理单图与批量处理:文件选择与输出规则

7.1 处理单图

流程:

  1. 弹出文件选择框选图
  2. 读取 QImage
  3. 调用水印渲染
  4. 保存为同目录 *_watermarked.ext
  5. 弹窗提示 "已生成 1 张图片"

对应实现位置:

  • MainWindow._process_single_image()

7.2 批量处理文件夹

流程:

  1. 选择文件夹
  2. 递归扫描图片文件扩展名:.jpg/.jpeg/.png/.bmp/.webp
  3. 输出目录:<选择的文件夹>/watermarked_output/
  4. 逐张保存,同名覆盖输出目录下同名文件
  5. 弹窗提示成功处理数量

对应实现位置:

  • iter_image_files()
  • MainWindow._process_folder()

8. 如何运行

8.1 安装依赖

bash 复制代码
pip install PyQt5

8.2 启动程序

在项目目录执行:

bash 复制代码
python main.py

9. 常见问题(FAQ)

9.1 为什么有些图片保存失败?

可能原因:

  • 图片路径包含特殊字符导致保存权限不足(建议输出到有写权限的目录)
  • 图片格式不支持写入(已支持常见格式;少见格式可先转为 PNG/JPG)

9.2 透明度为什么感觉不明显?

透明度是对"整个绘制操作"生效的,与背景颜色、图片亮度有关。深色图上浅色文字会更明显;如果不明显,可以:

  • 提高字体大小
  • 调整颜色对比
  • 提高透明度(更接近 100)

9.3 全屏水印密度怎么控制?

优先级:

  • 行数/列数 > 0,则用它们反推间距(更直观)
  • 否则用 水平间距/垂直间距 直接控制

10. 可扩展方向

如果你想把它升级成"更像产品"的工具,推荐从这些方向迭代:

  • 支持图片缩放后再加水印(例如输出统一宽度)
  • 支持输出质量/压缩率(JPG quality)
  • 支持水印阴影/描边(增强可读性)
  • 支持导出到自定义目录,而不是固定 watermarked_output
  • 批量处理加进度条与取消按钮(避免大文件夹卡住)
  • 支持图片 EXIF 方向纠正(部分手机照片会旋转)

11. 打包成可执行程序(Windows)

如果你想发给没有 Python 环境的同学使用,最常见做法是用 PyInstaller 打包。

11.1 安装 PyInstaller

bash 复制代码
pip install pyinstaller

11.2 一键打包(无控制台窗口)

在项目目录执行:

bash 复制代码
pyinstaller -F -w main.py --name 图片水印批量工具

打包完成后可执行文件通常在 dist/图片水印批量工具.exe

11.3 常见打包问题

  • 如果运行时报缺少 Qt 插件(例如 platform plugin),可以尝试升级 PyInstaller,或使用:
bash 复制代码
pyinstaller -F -w main.py --name 图片水印批量工具 --collect-all PyQt5

12. 关键代码入口

  • 程序入口:main() -> MainWindow()
  • 预览刷新:_update_preview()
  • 水印渲染:_apply_watermark() -> _draw_single() / _draw_tiled()
  • 批量扫描:iter_image_files()

如果你准备把项目拆成"UI + 业务 + 工具函数"的结构,也可以在后续把水印渲染独立成一个模块(例如 watermark.py),界面只负责读参数和调用即可。

完整代码

python 复制代码
import os
import sys
from dataclasses import dataclass
from typing import Iterable, Optional

from PyQt5.QtCore import Qt, QPointF
from PyQt5.QtGui import QColor, QFont, QFontMetricsF, QImage, QPainter, QPen, QPixmap
from PyQt5.QtWidgets import (
    QApplication,
    QCheckBox,
    QColorDialog,
    QComboBox,
    QDoubleSpinBox,
    QFileDialog,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QSizePolicy,
    QSpinBox,
    QVBoxLayout,
    QWidget,
)


@dataclass(frozen=True)
class WatermarkSettings:
    text: str
    font_size: int
    opacity: int
    color: QColor
    position: str
    fullscreen: bool
    rotation: float
    spacing_x: int
    spacing_y: int
    rows: int
    cols: int


SUPPORTED_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}


def iter_image_files(folder: str) -> Iterable[str]:
    for root, _, files in os.walk(folder):
        for name in files:
            ext = os.path.splitext(name)[1].lower()
            if ext in SUPPORTED_EXTS:
                yield os.path.join(root, name)


class MainWindow(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle("图片水印批量工具")
        self.resize(1100, 650)

        self._current_image_path: Optional[str] = None
        self._color = QColor(120, 0, 120)

        self._build_ui()
        self._wire_signals()
        self._update_color_button()
        self._update_preview()

    def _build_ui(self) -> None:
        root = QWidget(self)
        self.setCentralWidget(root)

        main_layout = QHBoxLayout(root)

        self.group_config = QGroupBox("水印参数配置", root)
        cfg_layout = QVBoxLayout(self.group_config)

        row1 = QHBoxLayout()
        row1.addWidget(QLabel("水印文字:", self.group_config))
        self.input_text = QLabel(self.group_config)
        self.input_text.setTextInteractionFlags(Qt.TextSelectableByMouse)
        self.text_value = QLabel(self.group_config)
        self.text_value.hide()
        self.text_edit = None
        from PyQt5.QtWidgets import QLineEdit

        self.text_edit = QLineEdit(self.group_config)
        self.text_edit.setText("@小庄-Python办公")
        row1.addWidget(self.text_edit, 1)
        cfg_layout.addLayout(row1)

        row2 = QHBoxLayout()
        row2.addWidget(QLabel("字体大小:", self.group_config))
        self.spin_font = QSpinBox(self.group_config)
        self.spin_font.setRange(6, 400)
        self.spin_font.setValue(40)
        row2.addWidget(self.spin_font)
        row2.addSpacing(20)
        row2.addWidget(QLabel("透明度(0-100):", self.group_config))
        self.spin_opacity = QSpinBox(self.group_config)
        self.spin_opacity.setRange(0, 100)
        self.spin_opacity.setValue(80)
        row2.addWidget(self.spin_opacity)
        row2.addStretch(1)
        cfg_layout.addLayout(row2)

        row3 = QHBoxLayout()
        row3.addWidget(QLabel("文字颜色:", self.group_config))
        self.btn_color = QPushButton("选择颜色", self.group_config)
        self.btn_color.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
        self.color_swatch = QLabel(self.group_config)
        self.color_swatch.setFixedSize(34, 22)
        self.color_swatch.setFrameShape(QLabel.Box)
        row3.addWidget(self.btn_color)
        row3.addWidget(self.color_swatch)
        row3.addStretch(1)
        cfg_layout.addLayout(row3)

        row4 = QHBoxLayout()
        row4.addWidget(QLabel("水印位置:", self.group_config))
        self.combo_pos = QComboBox(self.group_config)
        self.combo_pos.addItems(["左上", "右上", "左下", "右下", "居中"])
        self.combo_pos.setCurrentText("右下")
        row4.addWidget(self.combo_pos)
        row4.addStretch(1)
        cfg_layout.addLayout(row4)

        row5 = QHBoxLayout()
        self.chk_fullscreen = QCheckBox("全屏水印模式(忽略位置设置)", self.group_config)
        self.chk_fullscreen.setChecked(False)
        row5.addWidget(self.chk_fullscreen)
        row5.addStretch(1)
        cfg_layout.addLayout(row5)

        row6 = QHBoxLayout()
        row6.addWidget(QLabel("水印旋转角度:", self.group_config))
        self.spin_rotation = QDoubleSpinBox(self.group_config)
        self.spin_rotation.setRange(-180.0, 180.0)
        self.spin_rotation.setDecimals(1)
        self.spin_rotation.setSingleStep(1.0)
        self.spin_rotation.setValue(30.0)
        row6.addWidget(self.spin_rotation)
        row6.addStretch(1)
        cfg_layout.addLayout(row6)

        row7 = QHBoxLayout()
        row7.addWidget(QLabel("水平间距(像素):", self.group_config))
        self.spin_spacing_x = QSpinBox(self.group_config)
        self.spin_spacing_x.setRange(20, 5000)
        self.spin_spacing_x.setValue(360)
        row7.addWidget(self.spin_spacing_x)
        row7.addSpacing(20)
        row7.addWidget(QLabel("垂直间距(像素):", self.group_config))
        self.spin_spacing_y = QSpinBox(self.group_config)
        self.spin_spacing_y.setRange(20, 5000)
        self.spin_spacing_y.setValue(200)
        row7.addWidget(self.spin_spacing_y)
        row7.addStretch(1)
        cfg_layout.addLayout(row7)

        row8 = QHBoxLayout()
        row8.addWidget(QLabel("行数:", self.group_config))
        self.spin_rows = QSpinBox(self.group_config)
        self.spin_rows.setRange(0, 200)
        self.spin_rows.setValue(0)
        row8.addWidget(self.spin_rows)
        row8.addSpacing(20)
        row8.addWidget(QLabel("列数:", self.group_config))
        self.spin_cols = QSpinBox(self.group_config)
        self.spin_cols.setRange(0, 200)
        self.spin_cols.setValue(1)
        row8.addWidget(self.spin_cols)
        row8.addStretch(1)
        cfg_layout.addLayout(row8)

        cfg_layout.addStretch(1)

        btn_row = QHBoxLayout()
        self.btn_single = QPushButton("处理单个图片", self.group_config)
        self.btn_folder = QPushButton("批量处理文件夹", self.group_config)
        btn_row.addWidget(self.btn_single)
        btn_row.addWidget(self.btn_folder)
        cfg_layout.addLayout(btn_row)

        main_layout.addWidget(self.group_config, 0)

        self.group_preview = QGroupBox("预览", root)
        prev_layout = QVBoxLayout(self.group_preview)
        self.preview = QLabel(self.group_preview)
        self.preview.setAlignment(Qt.AlignCenter)
        self.preview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.preview.setMinimumSize(520, 520)
        self.preview.setStyleSheet("background: #1f1f1f; color: #dddddd;")
        prev_layout.addWidget(self.preview, 1)

        self.btn_choose_preview = QPushButton("选择预览图片", self.group_preview)
        prev_layout.addWidget(self.btn_choose_preview, 0, Qt.AlignRight)

        main_layout.addWidget(self.group_preview, 1)

    def _wire_signals(self) -> None:
        self.btn_color.clicked.connect(self._choose_color)
        self.btn_choose_preview.clicked.connect(self._choose_preview_image)
        self.btn_single.clicked.connect(self._process_single_image)
        self.btn_folder.clicked.connect(self._process_folder)

        for w in [
            self.text_edit,
            self.spin_font,
            self.spin_opacity,
            self.combo_pos,
            self.chk_fullscreen,
            self.spin_rotation,
            self.spin_spacing_x,
            self.spin_spacing_y,
            self.spin_rows,
            self.spin_cols,
        ]:
            if hasattr(w, "textChanged"):
                w.textChanged.connect(self._update_preview)
            if hasattr(w, "valueChanged"):
                w.valueChanged.connect(self._update_preview)
            if hasattr(w, "currentTextChanged"):
                w.currentTextChanged.connect(self._update_preview)
            if hasattr(w, "toggled"):
                w.toggled.connect(self._update_preview)

    def _settings(self) -> WatermarkSettings:
        return WatermarkSettings(
            text=self.text_edit.text().strip(),
            font_size=int(self.spin_font.value()),
            opacity=int(self.spin_opacity.value()),
            color=QColor(self._color),
            position=self.combo_pos.currentText(),
            fullscreen=self.chk_fullscreen.isChecked(),
            rotation=float(self.spin_rotation.value()),
            spacing_x=int(self.spin_spacing_x.value()),
            spacing_y=int(self.spin_spacing_y.value()),
            rows=int(self.spin_rows.value()),
            cols=int(self.spin_cols.value()),
        )

    def _choose_color(self) -> None:
        color = QColorDialog.getColor(self._color, self, "选择水印颜色")
        if color.isValid():
            self._color = color
            self._update_color_button()
            self._update_preview()

    def _update_color_button(self) -> None:
        self.color_swatch.setStyleSheet(
            f"background-color: {self._color.name()}; border: 1px solid #444;"
        )

    def _choose_preview_image(self) -> None:
        path, _ = QFileDialog.getOpenFileName(
            self,
            "选择图片",
            "",
            "Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)",
        )
        if not path:
            return
        self._current_image_path = path
        self._update_preview()

    def _process_single_image(self) -> None:
        src, _ = QFileDialog.getOpenFileName(
            self,
            "选择要处理的图片",
            "",
            "Images (*.png *.jpg *.jpeg *.bmp *.webp);;All Files (*)",
        )
        if not src:
            return

        img = QImage(src)
        if img.isNull():
            QMessageBox.warning(self, "错误", "图片读取失败")
            return

        dst = self._suggest_output_path(src)
        ok = self._save_watermarked(img, dst)
        if not ok:
            QMessageBox.warning(self, "错误", "图片保存失败")
            return

        self._current_image_path = src
        self._update_preview()
        QMessageBox.information(self, "批量完成", "处理成功,已生成 1 张图片")

    def _process_folder(self) -> None:
        folder = QFileDialog.getExistingDirectory(self, "选择要批量处理的文件夹", "")
        if not folder:
            return

        out_dir = os.path.join(folder, "watermarked_output")
        os.makedirs(out_dir, exist_ok=True)

        count = 0
        first_image = None
        for src in iter_image_files(folder):
            if os.path.commonpath([src, out_dir]) == out_dir:
                continue
            if first_image is None:
                first_image = src

            img = QImage(src)
            if img.isNull():
                continue

            base = os.path.basename(src)
            dst = os.path.join(out_dir, base)
            if self._save_watermarked(img, dst):
                count += 1

        if first_image:
            self._current_image_path = first_image
            self._update_preview()

        QMessageBox.information(self, "批量完成", f"批量处理结束!\n共成功处理 {count} 张图片")

    def _suggest_output_path(self, src: str) -> str:
        root, ext = os.path.splitext(src)
        return f"{root}_watermarked{ext}"

    def _save_watermarked(self, src_img: QImage, dst: str) -> bool:
        settings = self._settings()
        if not settings.text:
            return False
        out = self._apply_watermark(src_img, settings)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        return out.save(dst)

    def _apply_watermark(self, src_img: QImage, settings: WatermarkSettings) -> QImage:
        if src_img.format() != QImage.Format_ARGB32:
            base = src_img.convertToFormat(QImage.Format_ARGB32)
        else:
            base = QImage(src_img)

        out = QImage(base.size(), QImage.Format_ARGB32)
        out.fill(Qt.transparent)

        painter = QPainter(out)
        painter.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing)
        painter.drawImage(0, 0, base)

        font = QFont()
        font.setPixelSize(settings.font_size)
        painter.setFont(font)

        pen = QPen(settings.color)
        painter.setPen(pen)
        painter.setOpacity(max(0.0, min(1.0, settings.opacity / 100.0)))

        if settings.fullscreen:
            self._draw_tiled(painter, out.width(), out.height(), settings)
        else:
            self._draw_single(painter, out.width(), out.height(), settings)

        painter.end()
        return out

    def _draw_single(self, painter: QPainter, w: int, h: int, settings: WatermarkSettings) -> None:
        metrics = QFontMetricsF(painter.font())
        rect = metrics.boundingRect(settings.text)
        margin = 20.0

        if settings.position == "左上":
            x = margin
            y = margin + rect.height()
        elif settings.position == "右上":
            x = w - margin - rect.width()
            y = margin + rect.height()
        elif settings.position == "左下":
            x = margin
            y = h - margin
        elif settings.position == "右下":
            x = w - margin - rect.width()
            y = h - margin
        else:
            x = (w - rect.width()) / 2.0
            y = (h + rect.height()) / 2.0

        painter.save()
        painter.translate(x + rect.width() / 2.0, y - rect.height() / 2.0)
        painter.rotate(settings.rotation)
        painter.drawText(QPointF(-rect.width() / 2.0, rect.height() / 2.0), settings.text)
        painter.restore()

    def _draw_tiled(self, painter: QPainter, w: int, h: int, settings: WatermarkSettings) -> None:
        metrics = QFontMetricsF(painter.font())
        rect = metrics.boundingRect(settings.text)

        spacing_x = max(20, settings.spacing_x)
        spacing_y = max(20, settings.spacing_y)
        if settings.cols > 0:
            spacing_x = max(20, int(w / max(1, settings.cols)))
        if settings.rows > 0:
            spacing_y = max(20, int(h / max(1, settings.rows)))

        painter.save()
        painter.translate(w / 2.0, h / 2.0)
        painter.rotate(settings.rotation)

        start_x = -w
        end_x = w
        start_y = -h
        end_y = h

        x = start_x
        while x <= end_x:
            y = start_y
            while y <= end_y:
                painter.drawText(QPointF(x, y), settings.text)
                y += spacing_y
            x += spacing_x

        painter.restore()

    def _update_preview(self) -> None:
        if not self._current_image_path:
            self.preview.setText("请选择预览图片")
            return

        img = QImage(self._current_image_path)
        if img.isNull():
            self.preview.setText("预览图片读取失败")
            return

        settings = self._settings()
        if not settings.text:
            rendered = img
        else:
            rendered = self._apply_watermark(img, settings)

        pix = QPixmap.fromImage(rendered)
        target = self.preview.size()
        if target.width() <= 1 or target.height() <= 1:
            self.preview.setPixmap(pix)
            return
        self.preview.setPixmap(pix.scaled(target, Qt.KeepAspectRatio, Qt.SmoothTransformation))

    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        self._update_preview()


def main() -> int:
    app = QApplication(sys.argv)
    w = MainWindow()
    w.show()
    return app.exec_()


if __name__ == "__main__":
    raise SystemExit(main())

结尾

希望对初学者有帮助;致力于办公自动化的小小程序员一枚
希望能得到大家的【❤️一个免费关注❤️】感谢!
求个 🤞 关注 🤞 +❤️ 喜欢 ❤️ +👍 收藏 👍
此外还有办公自动化专栏,欢迎大家订阅:Python办公自动化专栏
此外还有爬虫专栏,欢迎大家订阅:Python爬虫基础专栏
此外还有Python基础专栏,欢迎大家订阅:Python基础学习专栏
相关推荐
超绝振刀怪2 小时前
【C++ vector】
开发语言·c++
机器视觉的发动机2 小时前
图像处理-机器视觉算法中的数学基础
开发语言·人工智能·算法·决策树·机器学习·视觉检测·机器视觉
guohahaya2 小时前
attention-2026
开发语言·c#
lntu_ling5 小时前
Python-基于Haversine公式计算两点距离
开发语言·python·gis算法
ShineWinsu10 小时前
对于C++:继承的解析—上
开发语言·数据结构·c++·算法·面试·笔试·继承
小付同学呀10 小时前
C语言学习(五)——输入/输出
c语言·开发语言·学习
梦幻精灵_cq10 小时前
学C之路:不可或缺的main()主函数框架(Learn-C 1st)
c语言·开发语言
消失的旧时光-194311 小时前
C++ 多线程与并发系统取向(二)—— 资源保护:std::mutex 与 RAII(类比 Java synchronized)
java·开发语言·c++·并发
福大大架构师每日一题12 小时前
go-zero v1.10.0发布!全面支持Go 1.23、MCP SDK迁移、性能与稳定性双提升
开发语言·后端·golang