第 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。