【我的开源】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 覆盖,不用起窗口:

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

几个体会

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

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

几个体会

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

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

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

相关推荐
阿文和她的Key1 小时前
DeepInfer 与 TorchTPU 同日开源:供应商锁定的成本结构正在被重写
网络·人工智能·架构·开源
用户0332126663671 小时前
Python 自动化办公:如何轻松在 PDF 中导入与导出表单数据
python
MoRanzhi12031 小时前
第5篇(上)· 使用 PyQt5 实现 2048 算法求解版:五类搜索算法内核
python·启发式算法·强化学习·搜索算法·蒙特卡洛树搜索·2048游戏·expectimax
星川皆无恙1 小时前
基于知识图谱的百科问答系统:Django + Neo4j 与实现
人工智能·python·机器学习·自然语言处理·django·知识图谱·neo4j
qq_448011161 小时前
EXEC字典沙箱仅是名字空间隔离,模块和解释器仍共享带来的坑
python
打工仔折腾 AI1 小时前
介入式脑机接口的跨尺度解码与闭环调控:从算法落地到后端工程实践
人工智能·后端·python·深度学习·算法·ai agent 实战
凯程序猿1号1 小时前
SafeDRM 的视频和 PDF 如何做跨设备交付验证?
pdf·电脑
FITA阿泽要努力2 小时前
/goal 命令:给 Agent 设定目标
服务器·人工智能·python·agent