ARTICLE · 1226927
【我的开源】Python实现一个离线 PDF 工具箱:架构、骑缝章与踩坑记录
为什么造一个 PDF 工具
需求很具体:政企办公场景里,合同、投标文件、扫描件需要批量合并拆分、加水印、盖章、盖骑缝章。在线工具的数据要上传,Stirling-PDF 要部署服务,现成的 Windows 小工具大多停止维护,骑缝章支持也不好。
所以目标定得很窄:
Windows 桌面,单窗口,批量处理 完全离线,不发起任何网络请求 核心功能做精,尤其是骑缝章 许可证宽松(Apache-2.0),政企可放心用
技术选型:

分层:core 不碰 GUI
src/pdf_gui/├── core/# 纯函数、 PDF 引擎,零 GUI 依赖├── gui/# 面板、控件、i18n、主题├── workers.py# 后台线程 + 事件队列└── app.py# 主窗口
core 层每个功能都返回统一的 TaskResult(ok, outputs, error),GUI 只负责收集参数和展示结果。好处是引擎层可以直接用 pytest 覆盖,不用起窗口:
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 轮询一次。这样进度条能实时走,取消也能在下一个文件前生效。
骑缝章:三个具体问题
骑缝章的物理模型不复杂:章盖在一叠纸的右侧切面上,每一页只分到一条竖条;打印叠放后,从侧边看竖条拼成完整的章。
实现分三步:
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 条恰好落在透明区域——预览只显示首末页,于是看到的全是空白,用户以为功能卡住了。
修复很简单,但必须做:
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 提供了把旋转烘焙进内容的方法:
rotation = int(page.get(”/Rotate”, 0) or 0) % 360if rotation:page.transfer_rotation_to_content()w, h = float(page.mediabox.width), float(page.mediabox.height)
烘焙后 MediaBox 变成视觉尺寸,再按常规的贴右边缘逻辑画即可。测试里专门构造了 /Rotate 90 的 PDF,渲染后断言红色像素仍贴着视觉右边缘、垂直居中。
问题三:预览把界面卡住
最初的预览直接在主线程跑 apply_paging_seal,22 页要跑一两秒,几百页更久。改成后台线程 + 队列轮询:
threading.Thread(target=self._run_preview, args=(...), daemon=True).start()self.after(80, self._poll_preview)
按钮在生成期间置灰并显示“正在生成预览…”,主线程只负责最后加载图片。
切条规则
页数不多时一页一条;超过 max_strips 时按 ceil(pages / max_strips) 页共用一条,保证每条的可见宽度不会细到打印不出来:
def strips_for_pages(pages: int, max_strips: int = 20) -> tuple[int, int]:if pages <= max_strips:return pages, 1per = ceil(pages / max_strips)return ceil(pages / per), per
这条规则也决定了打印效果:同一组的几页会显示同一条,叠放时章会被横向“拉宽”。这是刻意的取舍——优先保证每条可辨认,而不是追求严格的物理比例。
面板切换卡顿:缓存而不是重建
最初每次切换面板都 destroy() 再重新构造。CustomTkinter 的控件构造不便宜,一个面板有文件队列、滚动区、若干滑杆,切一次要几百毫秒。
改成懒加载 + 缓存:第一次进入时创建,之后只做 grid_remove() / grid():
panel = self.panels.get(cls)if panel is None:panel = cls(self.container, self)self.panels[cls] = panelpanel.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,所有控件自动生效:
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:
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),政企可放心用
技术选型:
分层:core 不碰 GUI
src/pdf_gui/├── core/# 纯函数 PDF 引擎,零 GUI 依赖├── gui/# 面板、控件、i18n、主题├── workers.py# 后台线程 + 事件队列└── app.py# 主窗口
core 层每个功能都返回统一的 TaskResult(ok, outputs, error),GUI 只负责收集参数和展示结果。好处是引擎层可以直接用 pytest 覆盖,不用起窗口:
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 轮询一次。这样进度条能实时走,取消也能在下一个文件前生效。
骑缝章:三个具体问题
骑缝章的物理模型不复杂:章盖在一叠纸的右侧切面上,每一页只分到一条竖条;打印叠放后,从侧边看竖条拼成完整的章。
实现分三步:
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 条恰好落在透明区域——预览只显示首末页,于是看到的全是空白,用户以为功能卡住了。
修复很简单,但必须做:
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 提供了把旋转烘焙进内容的方法:
rotation = int(page.get(”/Rotate”, 0) or 0) % 360if rotation:page.transfer_rotation_to_content()w, h = float(page.mediabox.width), float(page.mediabox.height)
烘焙后 MediaBox 变成视觉尺寸,再按常规的贴右边缘逻辑画即可。测试里专门构造了 /Rotate 90 的 PDF,渲染后断言红色像素仍贴着视觉右边缘、垂直居中。
问题三:预览把界面卡住
最初的预览直接在主线程跑 apply_paging_seal,22 页要跑一两秒,几百页更久。改成后台线程 + 队列轮询:
threading.Thread(target=self._run_preview, args=(...), daemon=True).start()self.after(80, self._poll_preview)
按钮在生成期间置灰并显示“正在生成预览…”,主线程只负责最后加载图片。
切条规则
页数不多时一页一条;超过 max_strips 时按 ceil(pages / max_strips) 页共用一条,保证每条的可见宽度不会细到打印不出来:
def strips_for_pages(pages: int, max_strips: int = 20) -> tuple[int, int]:if pages <= max_strips:return pages, 1per = ceil(pages / max_strips)return ceil(pages / per), per
这条规则也决定了打印效果:同一组的几页会显示同一条,叠放时章会被横向“拉宽”。这是刻意的取舍——优先保证每条可辨认,而不是追求严格的物理比例。
面板切换卡顿:缓存而不是重建
最初每次切换面板都 destroy() 再重新构造。CustomTkinter 的控件构造不便宜,一个面板有文件队列、滚动区、若干滑杆,切一次要几百毫秒。
改成懒加载 + 缓存:第一次进入时创建,之后只做 grid_remove() / grid():
panel = self.panels.get(cls)if panel is None:panel = cls(self.container, self)self.panels[cls] = panelpanel.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,所有控件自动生效:
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:
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 卡顿的问题,也可以在评论区说说你的场景。















































































