PyQt6-第10讲-PDF工具箱

第 10 讲 | PDF 工具箱:合并、拆分、水印、加密

本讲目标 :做一个覆盖 5 种常用操作的 PDF 工具,并掌握 QStackedWidget 这个"多面板切换"控件。

配套代码 :cases/c10_pdf_toolbox.py


一、需求:一个工具覆盖 5 种操作

PDF 处理的需求五花八门,但常用的就五类:

操作 典型场景
合并 把 10 份扫描件合成一个文件提交
拆分 一份 100 页的合同拆成单页,方便逐页签字
加水印 内部资料、加密文件加"机密"字样
加密 对外发敏感文件时设密码
旋转 扫描时放反了,整份转 90 度

问题来了:这五种操作的参数完全不同 。合并不需要参数;拆分要问"每几页拆一个";

加水印要输入水印文字;加密要密码;旋转要角度。

怎么设计界面?三种方案:

方案 A:全塞一个界面 ------ 所有参数框都显示出来,用户自己看该填哪个。丑且易错。

方案 B:弹对话框 ------ 选操作后弹一个对话框选参数。多一次交互,麻烦。

方案 C:QStackedWidget ------ 一个区域放多个参数面板,选择操作时自动切换。本讲用这个。


二、QStackedWidget:一个区域,多个页面

QStackedWidget 是"层叠容器":它内部可以放多个控件,但同一时刻只显示一个。

python 复制代码
self.stack = QStackedWidget()
self.stack.addWidget(self._page_merge())       # 索引 0
self.stack.addWidget(self._page_split())       # 索引 1
self.stack.addWidget(self._page_watermark())   # 索引 2
self.stack.addWidget(self._page_password())    # 索引 3
self.stack.addWidget(self._page_rotate())      # 索引 4

# 下拉框索引变化 → 切换面板
self.combo.currentIndexChanged.connect(self.stack.setCurrentIndex)

关键点 :QComboBox 的 currentIndexChanged 传出的 int,正好就是

QStackedWidget.setCurrentIndex() 需要的 int ------ 类型对上了,直接连 。

(回顾第 3 讲:这就是"内置槽"写法的典型用法。)

于是下拉框选第 2 项,stack 就自动显示第 2 个面板。一行 connect 搞定。

每个参数页的写法

python 复制代码
def _page_split(self) -> QWidget:
    w = QWidget()
    self.spin_every = QSpinBox()
    self.spin_every.setRange(1, 200)
    self.spin_every.setValue(1)
    self.spin_every.setSuffix(" 页 / 个文件")
    form = QFormLayout(w)                       # ★ 直接以 w 为父控件
    form.addRow("每多少页拆一个:", self.spin_every)
    return w

def _page_watermark(self) -> QWidget:
    w = QWidget()
    self.edit_wm = QLineEdit("内部资料 请勿外传")
    form = QFormLayout(w)
    form.addRow("水印文字:", self.edit_wm)
    return w

注意 QFormLayout(w) 的写法 :把 w 作为父控件传进去,布局就属于这个页面了。

这是第 2 讲讲过的两种写法之一,在这里最简洁。

然后取值:

python 复制代码
params = {
    "every": self.spin_every.value(),
    "text": self.edit_wm.text().strip(),
    "password": self.edit_pwd.text().strip(),
    "angle": self.spin_angle.value(),
}

参数全部收集,任务函数按 action 挑自己需要的用。 简单直接,

不用去判断"当前显示的是哪个面板"。


三、pypdf 的五个核心操作

pypdf 是目前最活跃的纯 Python PDF 库(PyPDF2 是它的前身,已停止维护)。

通用结构

python 复制代码
from pypdf import PdfReader, PdfWriter

reader = PdfReader("input.pdf")
print(len(reader.pages))                    # 页数

writer = PdfWriter()
for page in reader.pages:
    writer.add_page(page)                   # 加页

with open("output.pdf", "wb") as fp:        # ★ 必须是 "wb" 二进制模式
    writer.write(fp)

三个要点:

  • reader.pages 是可迭代对象,用 reader.pages[0] 取单页
  • writer.add_page(page) 每次加一页
  • 写文件必须用 "wb" ,用 "w" 会报编码错

① 合并

python 复制代码
writer = PdfWriter()
for f in files:
    reader = PdfReader(f)
    for page in reader.pages:
        writer.add_page(page)
with open(out / "合并结果.pdf", "wb") as fp:
    writer.write(fp)

每一个源文件都要新建 PdfReader,别想着复用。

② 拆分

python 复制代码
reader = PdfReader(f)
step = 2                                     # 每 2 页一个文件
for start in range(0, len(reader.pages), step):
    sub = PdfWriter()
    for page in reader.pages[start:start + step]:
        sub.add_page(page)
    name = f"{src.stem}_第{start + 1}-{min(start + step, len(reader.pages))}页.pdf"
    with open(out / name, "wb") as fp:
        sub.write(fp)

命名带上页码范围 (第1-2页)比 _1、_2 清楚得多,用户一眼知道内容在哪。

③ 加水印(最有技术含量)

水印不是"在 PDF 上画字",而是把一页透明的水印 PDF 叠到每一页上。

第一步:用 reportlab 生成水印页

python 复制代码
def _make_watermark_pdf(text: str, page_w: float, page_h: float) -> io.BytesIO:
    from reportlab.lib.colors import Color
    from reportlab.pdfgen import canvas

    buf = io.BytesIO()                       # 在内存里生成,不落盘
    c = canvas.Canvas(buf, pagesize=(page_w, page_h))

    c.saveState()
    c.setFillColor(Color(0.6, 0.6, 0.6, alpha=0.28))     # ★ 半透明灰
    c.setFont("STSong-Light", 42)

    # 把坐标系原点移到页面中心,再旋转 45 度 → 文字就是斜的
    c.translate(page_w / 2, page_h / 2)
    c.rotate(45)
    c.drawCentredString(0, 0, text)
    c.restoreState()

    c.save()
    buf.seek(0)
    return buf

三个关键点:

  • alpha=0.28:水印要半透明,不然完全遮住正文
  • translate + rotate :这是画斜向文字的标准做法,
    rotate(45) 后坐标系也跟着转了,此时 drawCentredString(0, 0, ...) 画在页面正中央
  • STSong-Light :reportlab 内置的中文 CID 字体,不需要额外字体文件 。
    用默认的 Helvetica 写中文会输出一堆黑方块

第二步:叠到每一页

python 复制代码
writer = PdfWriter()
for page in reader.pages:
    page.merge_page(wm.pages[0])            # ★ 把水印页合并上去
    writer.add_page(page)

页面尺寸要取源文件的:

python 复制代码
first = reader.pages[0]
w = float(first.mediabox.width)
h = float(first.mediabox.height)
wm_buf = _make_watermark_pdf(text, w, h)

如果水印页是 A4 而源文件是 A3,水印就会只占左上角一小块。

④ 加密

python 复制代码
pwd = "123456"
if reader.is_encrypted:
    reader.decrypt(pwd)                     # 已加密的先解开
writer = PdfWriter()
for page in reader.pages:
    writer.add_page(page)
writer.encrypt(pwd)                         # 加新密码
with open(out / f"{src.stem}_加密.pdf", "wb") as fp:
    writer.write(fp)

reader.is_encrypted 这个判断很必要 :如果用户把一个已加密的 PDF 拖进来,

不先 decrypt() 就读页面会直接抛异常。

⑤ 旋转

python 复制代码
for page in reader.pages:
    page.rotate(angle)                      # ★ 逆时针旋转
    writer.add_page(page)

注意是逆时针 。想让内容顺时针转 90 度(扫描放反的常见情况),传 -90。


四、界面:文件列表的两个实用设计

① 多选 + 去重

python 复制代码
self.list_files.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)

def _add_paths(self, paths: list[str]) -> None:
    existing = {self.list_files.item(i).data(0x0100)
                for i in range(self.list_files.count())}
    added = 0
    for p in paths:
        if p in existing:
            continue                        # ★ 去重
        item = QListWidgetItem(Path(p).name)
        item.setToolTip(p)                  # 悬停显示完整路径
        item.setData(0x0100, p)             # ★ 完整路径藏在 UserRole 里
        self.list_files.addItem(item)
        added += 1

两点值得学:

  • setData(0x0100, p) (0x0100 就是 Qt.ItemDataRole.UserRole)

    是专业写法 :界面显示文件名(短、好看),程序里用完整路径(长、有用)。

    取值时 item.data(0x0100) 直接拿到。

  • 去重:用户很容易重复添加同一个文件,不去重的话合并结果里会有重复页。

② "添加整个目录"

python 复制代码
def _add_dir(self) -> None:
    d = QFileDialog.getExistingDirectory(self, "选择包含 PDF 的目录")
    if d:
        self._add_paths([str(p) for p in sorted(Path(d).glob("*.pdf"))])

比让用户一个个选文件快得多。支持目录批量导入是工具类软件的基本礼貌。


五、异常隔离:一个坏文件不能拖垮整批

python 复制代码
for i, f in enumerate(files, 1):
    try:
        ... 处理这个文件 ...
        done += 1
    except Exception as exc:
        errors.append(f"{Path(f).name}: {exc}")
        report(int(i / total * 95), f"[{i}/{total}] {Path(f).name} 失败,已跳过")

每个文件独立 try/except,而不是整个循环包一个 try。

原因:如果 20 个文件里第 5 个损坏了,整体 try 会让后面 15 个全都不处理。

独立 try 则跳过坏的,其他照常处理,最后汇报"成功 19 个,失败 1 个"。

errors 列表最后返回给界面,只显示前 8 条,避免刷屏。


六、三个坑

坑 1:写文件用了 "w" 而不是 "wb"

报 TypeError: write() argument must be str, not bytes。PDF 是二进制,一律 "wb"。

坑 2:水印把正文盖住了

水印的 alpha 忘了设,或者设成了 1.0。建议 0.2 ~ 0.35 之间。

坑 3:合并后页数不对

最常见的原因是文件列表里有重复项(去重解决),或者某个源文件本身就是加密的

(reader.pages 会报错)。加 reader.is_encrypted 判断并用密码解开。


七、小结

  • QStackedWidget :一个区域装多个面板,用 currentIndexChanged 直接切换
  • pypdf 五个操作 :合并(逐个 add_page)、拆分(切片)、水印(叠加透明页)、
    加密(encrypt)、旋转(rotate,逆时针)
  • 水印三要素 :半透明、translate+rotate 画斜字、页面尺寸跟随源文件
  • 异常隔离:每个文件独立 try/except,好的处理、坏的跳过

下一讲 :文件批量重命名。这是本课程最实用的一个工具 ,

因为它解决了一个让所有人都害怕的问题------批量操作改错了怎么办 。

答案是一套完整的"预览 + 执行 + 一键撤销"机制,学完你就能把这个模式用到所有危险操作上。


本讲是《PyQt6 客户端编程从入门到实战》专栏的第 10 讲。专栏共 22 讲、22 个可运行案例。

代码下载:git clone https://gitee.com/wuyikui555/pyqt6-client.git。

相关推荐
知识分享小能手1 小时前
机器学习教程,从入门到精通,机器学习概述 + Python 语言基础 · 知识点详解(1)
人工智能·python·机器学习
郝学胜-神的一滴1 小时前
AI 编程智能体 16:从零基础搭建到精细化优化
人工智能·python·算法·语言模型·langchain·pip
又蓝2 小时前
【元宝搜索开发者征文大赛】用 FastAPI + Vue3 做了个 AI 炒股工具,接了六个分析师和腾讯元宝搜索
人工智能·python·ai·fastapi
2601_966949652 小时前
为什么不应该把 QuantDash API Key 直接写进公开代码仓库?从 Git 提交历史理解凭证泄露风险
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
在世修行2 小时前
智学文枢:文科分析引擎
python·ai·pyside·文科分析
包小黑2 小时前
Python 转 EXE (实用工具)
开发语言·python
全栈练习生2 小时前
DeepSeek 稀疏注意力
python·ai
我命由我123452 小时前
6-3.Python 异常处理 - 异常处理进阶(else 块与 finally 块、异常传递、自定义异常)
开发语言·前端·数据库·后端·python·学习·java-ee
FYKJ_20102 小时前
springboot高考志愿填报推荐系统14332-计算机课程设计、毕业设计
javascript·spring boot·python·架构·django·课程设计·高考