为什么造一个 PDF 工具
需求很具体:政企办公场景里,合同、投标文件、扫描件需要批量合并拆分、加水印、盖章、盖骑缝章。在线工具的数据要上传,Stirling-PDF 要部署服务,现成的 Windows 小工具大多停止维护,骑缝章支持也不好。
所以目标定得很窄:
- Windows 桌面,单窗口,批量处理
- 完全离线,不发起任何网络请求
- 核心功能做精,尤其是骑缝章
- 许可证宽松(Apache-2.0),政企可放心用
技术选型:
| 需求 | 选择 | 理由 |
|---|---|---|
| GUI | CustomTkinter | tkinter 系,零额外运行时,打包体积可控 |
| PDF 结构 | pypdf | BSD,读写、加密、页面合并都够用 |
| overlay 生成 | reportlab | 水印、印章、切条都用同一套 Canvas API |
| 图片处理 | Pillow | 裁边、切条、合成 |
| 渲染/预览 | pypdfium2 | Apache-2.0/BSD,渲染快,同时负责 PDF→图片 |
| 打包 | PyInstaller onedir | 杀软误报率低于 onefile |

分层:core 不碰 GUI
src/pdf_gui/
├── core/ # 纯函数 PDF 引擎,零 GUI 依赖
├── gui/ # 面板、控件、i18n、主题
├── workers.py # 后台线程 + 事件队列
└── app.py # 主窗口
core 层每个功能都返回统一的 TaskResult(ok, outputs, error),GUI 只负责收集参数和展示结果。好处是引擎层可以直接用 pytest 覆盖,不用起窗口:
python
def apply_paging_seal(src, dst, stamp_png, *, seal_height_ratio=0.4,
y_center_ratio=0.5, max_strips=20,
progress_cb=None) -> TaskResult:
...
批处理时,BatchWorker 在后台线程里逐个执行 Job,通过 queue.Queue 往主线程发 job_start / job_progress / job_done / finish 四种事件,主窗口每 100ms 轮询一次。这样进度条能实时走,取消也能在下一个文件前生效。
骑缝章:三个具体问题
骑缝章的物理模型不复杂:章盖在一叠纸的右侧切面上,每一页只分到一条竖条;打印叠放后,从侧边看竖条拼成完整的章。
实现分三步:
python
img = trim_to_content(Image.open(stamp_png).convert("RGBA"))
n_strips, pages_per_strip = strips_for_pages(total_pages, max_strips)
strips = split_stamp_image(img, n_strips)
问题一:透明边把首末页"吃掉"了
一开始直接在原图上切条。用户的印章 PNG 是 460×460,真正的章只占中间一部分,四周全是透明像素。22 页文档切成 11 条后,第 1 条和第 11 条恰好落在透明区域------预览只显示首末页,于是看到的全是空白,用户以为功能卡住了。
修复很简单,但必须做:
python
def trim_to_content(img: Image.Image) -> Image.Image:
box = img.getchannel("A").getbbox()
if box is None:
raise ValueError("印章图片为空(全透明)")
return img.crop(box)
顺带加了一条 UI 说明:面板里先显示拼合示意(所有竖条并排还原完整印章),再显示首末页,用户一眼就能确认章切得对不对。
问题二:旋转页把"右边缘"画到了底边
扫描件常带 /Rotate 90。PDF 的 MediaBox 可能还是竖的,但阅读器显示时会把页面转 90°。此时按 MediaBox 的 w - strip_w 画,视觉上条子会跑到页面的底边或顶边。
pypdf 提供了把旋转烘焙进内容的方法:
python
rotation = int(page.get("/Rotate", 0) or 0) % 360
if rotation:
page.transfer_rotation_to_content()
w, h = float(page.mediabox.width), float(page.mediabox.height)
烘焙后 MediaBox 变成视觉尺寸,再按常规的贴右边缘逻辑画即可。测试里专门构造了 /Rotate 90 的 PDF,渲染后断言红色像素仍贴着视觉右边缘、垂直居中。
问题三:预览把界面卡住
最初的预览直接在主线程跑 apply_paging_seal,22 页要跑一两秒,几百页更久。改成后台线程 + 队列轮询:
python
threading.Thread(target=self._run_preview, args=(...), daemon=True).start()
self.after(80, self._poll_preview)
按钮在生成期间置灰并显示"正在生成预览...",主线程只负责最后加载图片。
切条规则
页数不多时一页一条;超过 max_strips 时按 ceil(pages / max_strips) 页共用一条,保证每条的可见宽度不会细到打印不出来:
python
def strips_for_pages(pages: int, max_strips: int = 20) -> tuple[int, int]:
if pages <= max_strips:
return pages, 1
per = ceil(pages / max_strips)
return ceil(pages / per), per
这条规则也决定了打印效果:同一组的几页会显示同一条,叠放时章会被横向"拉宽"。这是刻意的取舍------优先保证每条可辨认,而不是追求严格的物理比例。
面板切换卡顿:缓存而不是重建
最初每次切换面板都 destroy() 再重新构造。CustomTkinter 的控件构造不便宜,一个面板有文件队列、滚动区、若干滑杆,切一次要几百毫秒。
改成懒加载 + 缓存 :第一次进入时创建,之后只做 grid_remove() / grid():
python
panel = self.panels.get(cls)
if panel is None:
panel = cls(self.container, self)
self.panels[cls] = panel
panel.grid(row=0, column=0, sticky="nsew")
else:
panel.grid()
副作用是个好结果:在某个面板里添加的文件,切走再回来还在。只有切换语言时才销毁全部面板重建,因为所有文案藏在控件里。
双语与主题
界面文案统一走 gui/i18n.py 的字典 + t(),中英两份。语言切换时重建导航和已创建的面板,未打开过的面板下次再建。
配色没有硬编码。gui/theme.py 定义了一组语义 token(window_bg、card_bg、accent、text、muted_text 等),默认值就是 CustomTkinter 内置蓝主题;用户编辑 ~\.pdf_office_toolkit_theme.json 后,启动时把这些值注入 ThemeManager.theme,所有控件自动生效:
python
put("CTk", "fg_color", "window_bg")
put("CTkFrame", "fg_color", "card_bg")
put("CTkButton", "fg_color", "accent")
put("CTkButton", "hover_color", "accent_hover")
配置文件支持单色(明暗通用)或 [浅色, 深色] 数组,非法值静默回退。后续想加"配色编辑器",只需要改这个文件并重建窗口。
测试与打包
- 引擎层 102 个 pytest 用例,覆盖合并、拆分、水印、盖章、骑缝章、加密、压缩、转图片、页码解析、i18n、设置、主题。
- 测试样本用 reportlab 现场生成,不依赖二进制夹具。
- GitHub Actions 在 Windows 上跑 ruff + pytest,再执行
build_win.ps1打包,上传 zip。
打包命令的核心是 --onedir 和几个 collect:
powershell
pyinstaller --noconfirm --clean --onedir --windowed --name pdf-office-toolkit `
--collect-all customtkinter --collect-all pypdfium2 `
--collect-data pdf_gui `
--hidden-import PIL._tkinter_finder `
src/pdf_gui/main.py
--collect-data pdf_gui 是为了把 app_meta.json 带进包;PIL._tkinter_finder 是 Pillow 在打包环境里的常见坑。
几个体会
- 先定义"正确"再看图。骑缝章的条子贴在右边缘、垂直居中,肉眼不一定立刻理解;用测试断言像素 bbox,比反复截图靠谱。
- 预览要解释原理。用户看到每页只有一条细线,很容易以为坏了;加一张拼合示意图,比写十行帮助文字有效。
- GUI 性能问题大多来自"重建"。缓存、懒加载、后台线程,三招能解决大部分卡顿。
- 别把在线体验带进来。项目里没有任何遥测、检查更新、字体下载,打包后断网也能完整运行------这本身就是卖点。
仓库:https://github.com/moduwusuowei/pdf-office-toolkit
欢迎 Issue / PR。如果这篇文章帮你解决了骑缝章或 GUI 卡顿的问题,也可以在评论区说说你的场景。# 用 Python + CustomTkinter 做一个离线 PDF 工具箱:架构、骑缝章与踩坑记录
技术博客草稿,适合发在掘金 / 知乎 / 个人博客。代码片段来自仓库 v0.1.0。
为什么又造一个 PDF 工具
需求很具体:政企办公场景里,合同、投标文件、扫描件需要批量合并拆分、加水印、盖章、盖骑缝章。在线工具的数据要上传,Stirling-PDF 要部署服务,现成的 Windows 小工具大多停止维护,骑缝章支持也不好。
所以目标定得很窄:
- Windows 桌面,单窗口,批量处理
- 完全离线,不发起任何网络请求
- 核心功能做精,尤其是骑缝章
- 许可证宽松(Apache-2.0),政企可放心用
技术选型:
| 需求 | 选择 | 理由 |
|---|---|---|
| GUI | CustomTkinter | tkinter 系,零额外运行时,打包体积可控 |
| PDF 结构 | pypdf | BSD,读写、加密、页面合并都够用 |
| overlay 生成 | reportlab | 水印、印章、切条都用同一套 Canvas API |
| 图片处理 | Pillow | 裁边、切条、合成 |
| 渲染/预览 | pypdfium2 | Apache-2.0/BSD,渲染快,同时负责 PDF→图片 |
| 打包 | PyInstaller onedir | 杀软误报率低于 onefile |
分层:core 不碰 GUI
src/pdf_gui/
├── core/ # 纯函数 PDF 引擎,零 GUI 依赖
├── gui/ # 面板、控件、i18n、主题
├── workers.py # 后台线程 + 事件队列
└── app.py # 主窗口
core 层每个功能都返回统一的 TaskResult(ok, outputs, error),GUI 只负责收集参数和展示结果。好处是引擎层可以直接用 pytest 覆盖,不用起窗口:
python
def apply_paging_seal(src, dst, stamp_png, *, seal_height_ratio=0.4,
y_center_ratio=0.5, max_strips=20,
progress_cb=None) -> TaskResult:
...
批处理时,BatchWorker 在后台线程里逐个执行 Job,通过 queue.Queue 往主线程发 job_start / job_progress / job_done / finish 四种事件,主窗口每 100ms 轮询一次。这样进度条能实时走,取消也能在下一个文件前生效。
骑缝章:三个具体问题
骑缝章的物理模型不复杂:章盖在一叠纸的右侧切面上,每一页只分到一条竖条;打印叠放后,从侧边看竖条拼成完整的章。
实现分三步:
python
img = trim_to_content(Image.open(stamp_png).convert("RGBA"))
n_strips, pages_per_strip = strips_for_pages(total_pages, max_strips)
strips = split_stamp_image(img, n_strips)
问题一:透明边把首末页"吃掉"了
一开始直接在原图上切条。用户的印章 PNG 是 460×460,真正的章只占中间一部分,四周全是透明像素。22 页文档切成 11 条后,第 1 条和第 11 条恰好落在透明区域------预览只显示首末页,于是看到的全是空白,用户以为功能卡住了。
修复很简单,但必须做:
python
def trim_to_content(img: Image.Image) -> Image.Image:
box = img.getchannel("A").getbbox()
if box is None:
raise ValueError("印章图片为空(全透明)")
return img.crop(box)
顺带加了一条 UI 说明:面板里先显示拼合示意(所有竖条并排还原完整印章),再显示首末页,用户一眼就能确认章切得对不对。
问题二:旋转页把"右边缘"画到了底边
扫描件常带 /Rotate 90。PDF 的 MediaBox 可能还是竖的,但阅读器显示时会把页面转 90°。此时按 MediaBox 的 w - strip_w 画,视觉上条子会跑到页面的底边或顶边。
pypdf 提供了把旋转烘焙进内容的方法:
python
rotation = int(page.get("/Rotate", 0) or 0) % 360
if rotation:
page.transfer_rotation_to_content()
w, h = float(page.mediabox.width), float(page.mediabox.height)
烘焙后 MediaBox 变成视觉尺寸,再按常规的贴右边缘逻辑画即可。测试里专门构造了 /Rotate 90 的 PDF,渲染后断言红色像素仍贴着视觉右边缘、垂直居中。
问题三:预览把界面卡住
最初的预览直接在主线程跑 apply_paging_seal,22 页要跑一两秒,几百页更久。改成后台线程 + 队列轮询:
python
threading.Thread(target=self._run_preview, args=(...), daemon=True).start()
self.after(80, self._poll_preview)
按钮在生成期间置灰并显示"正在生成预览...",主线程只负责最后加载图片。
切条规则
页数不多时一页一条;超过 max_strips 时按 ceil(pages / max_strips) 页共用一条,保证每条的可见宽度不会细到打印不出来:
python
def strips_for_pages(pages: int, max_strips: int = 20) -> tuple[int, int]:
if pages <= max_strips:
return pages, 1
per = ceil(pages / max_strips)
return ceil(pages / per), per
这条规则也决定了打印效果:同一组的几页会显示同一条,叠放时章会被横向"拉宽"。这是刻意的取舍------优先保证每条可辨认,而不是追求严格的物理比例。
面板切换卡顿:缓存而不是重建
最初每次切换面板都 destroy() 再重新构造。CustomTkinter 的控件构造不便宜,一个面板有文件队列、滚动区、若干滑杆,切一次要几百毫秒。
改成懒加载 + 缓存 :第一次进入时创建,之后只做 grid_remove() / grid():
python
panel = self.panels.get(cls)
if panel is None:
panel = cls(self.container, self)
self.panels[cls] = panel
panel.grid(row=0, column=0, sticky="nsew")
else:
panel.grid()
副作用是个好结果:在某个面板里添加的文件,切走再回来还在。只有切换语言时才销毁全部面板重建,因为所有文案藏在控件里。
双语与主题
界面文案统一走 gui/i18n.py 的字典 + t(),中英两份。语言切换时重建导航和已创建的面板,未打开过的面板下次再建。
配色没有硬编码。gui/theme.py 定义了一组语义 token(window_bg、card_bg、accent、text、muted_text 等),默认值就是 CustomTkinter 内置蓝主题;用户编辑 ~\.pdf_office_toolkit_theme.json 后,启动时把这些值注入 ThemeManager.theme,所有控件自动生效:
python
put("CTk", "fg_color", "window_bg")
put("CTkFrame", "fg_color", "card_bg")
put("CTkButton", "fg_color", "accent")
put("CTkButton", "hover_color", "accent_hover")
配置文件支持单色(明暗通用)或 [浅色, 深色] 数组,非法值静默回退。后续想加"配色编辑器",只需要改这个文件并重建窗口。
水印效果:
操作部分:

结果部分:

测试与打包
- 引擎层 102 个 pytest 用例,覆盖合并、拆分、水印、盖章、骑缝章、加密、压缩、转图片、页码解析、i18n、设置、主题。
- 测试样本用 reportlab 现场生成,不依赖二进制夹具。
- GitHub Actions 在 Windows 上跑 ruff + pytest,再执行
build_win.ps1打包,上传 zip。
打包命令的核心是 --onedir 和几个 collect:
powershell
pyinstaller --noconfirm --clean --onedir --windowed --name pdf-office-toolkit `
--collect-all customtkinter --collect-all pypdfium2 `
--collect-data pdf_gui `
--hidden-import PIL._tkinter_finder `
src/pdf_gui/main.py
--collect-data pdf_gui 是为了把 app_meta.json 带进包;PIL._tkinter_finder 是 Pillow 在打包环境里的常见坑。
几个体会
- 先定义"正确"再看图。骑缝章的条子贴在右边缘、垂直居中,肉眼不一定立刻理解;用测试断言像素 bbox,比反复截图靠谱。
- 预览要解释原理。用户看到每页只有一条细线,很容易以为坏了;加一张拼合示意图,比写十行帮助文字有效。
- GUI 性能问题大多来自"重建"。缓存、懒加载、后台线程,三招能解决大部分卡顿。
- 别把在线体验带进来。项目里没有任何遥测、检查更新、字体下载,打包后断网也能完整运行------这本身就是卖点。
仓库:https://github.com/moduwusuowei/pdf_office_toolkit
欢迎 Issue / PR。如果这篇文章帮你解决了骑缝章或 GUI 卡顿的问题,也可以在评论区说说你的场景。