夜雨聆风学习资料网

ARTICLE · 1226927

【我的开源】Python实现一个离线 PDF 工具箱:架构、骑缝章与踩坑记录

【我的开源】Python实现一个离线 PDF 工具箱:架构、骑缝章与踩坑记录

为什么造一个 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 覆盖,不用起窗口:

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, 1    per = 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] = 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,所有控件自动生效:

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 在打包环境里的常见坑。

几个体会

  1. 先定义“正确”再看图。骑缝章的条子贴在右边缘、垂直居中,肉眼不一定立刻理解;用测试断言像素 bbox,比反复截图靠谱。
  2. 预览要解释原理。用户看到每页只有一条细线,很容易以为坏了;加一张拼合示意图,比写十行帮助文字有效。
  3. GUI 性能问题大多来自“重建”。缓存、懒加载、后台线程,三招能解决大部分卡顿。
  4. 别把在线体验带进来。项目里没有任何遥测、检查更新、字体下载,打包后断网也能完整运行——这本身就是卖点。

仓库: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 覆盖,不用起窗口:

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, 1    per = 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] = 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,所有控件自动生效:

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 在打包环境里的常见坑。

几个体会 

  1. 先定义“正确”再看图。骑缝章的条子贴在右边缘、垂直居中,肉眼不一定立刻理解;用测试断言像素 bbox,比反复截图靠谱。
  2. 预览要解释原理。用户看到每页只有一条细线,很容易以为坏了;加一张拼合示意图,比写十行帮助文字有效。
  3. GUI 性能问题大多来自“重建”。缓存、懒加载、后台线程,三招能解决大部分卡顿。
  4. 别把在线体验带进来。项目里没有任何遥测、检查更新、字体下载,打包后断网也能完整运行——这本身就是卖点。

仓库:https://github.com/moduwusuowei/pdf_office_toolkit

欢迎 Issue / PR。如果这篇文章帮你解决了骑缝章或 GUI 卡顿的问题,也可以在评论区说说你的场景。

相关学习资料