华为云码道从 0 到 1 开发跨端 PDF 保功能压缩工具
用华为云码道从 0 到 1 开发跨端 PDF 保功能压缩工具:7 个压缩原语、4 档预设与 6 个真实踩坑
项目:
pdfintact(保功能 PDF 压缩工具)
环境:Windows 11、Python 3.12.10、Node.js 22
后端包管理:uv;前端包管理:pnpm
开源仓库(AtomGit):https://atomgit.com/CYXue/pdfintact
文中压缩数据由本机脚本实测,复现方法见文末。
0. 先看结果
我用华为云码道 CodeArts从 0 到 1 开发了一个 PDF 工具 pdfintact。它以本地服务为核心,提供浏览器前端和命令行两种入口;PDF 在本机处理,不需要上传到云端。
它的目标不只是"压得更小",而是尽量做到压缩后仍然可用:无损、均衡、极限三档保留文本、书签和搜索能力;超极限档通过整页栅格化换取更高压缩率,同时明确告知用户功能损失。压缩完成后,工具还会生成一份功能变更清单,逐项检查文字可选中复制、书签可跳转、文字显示正常、全文可搜索、图像质量共 5 个维度。
一份 70 页的中文教材从 21.57 MB 压到 5.41 MB,压缩率 74.90%。不过,最激进的档位并不总是最有效:文本和矢量内容为主的 PDF,整页栅格化后反而可能大幅膨胀。这也是为什么工具既要报告压缩率,也要说明压缩前后的功能变化。





1. 为什么需要"保功能"压缩
项目的起点是一批体积较大的 PDF 资料。在线压缩虽然方便,但敏感文件需要上传;一些离线工具压完后,又可能出现书签失效、文本变成图片、无法复制搜索,或者字体显示异常等问题。
PDF 体积大,并不总是因为页面内容本身太多。常见原因还包括嵌入了完整字体、存在重复对象流、保存了冗余元数据,或包含高分辨率图像。其中一部分可以通过无损清理减小体积;另一些操作,例如整页栅格化、移除嵌入字体或降低图像质量,则会带来功能或画质上的取舍。
因此,pdfintact 的产品目标被定义为:
- 尽量保留文本可选、可搜索和书签可跳转等能力;
- 全程在本机处理 PDF,避免把文件上传到云端;
- 对有损或可能破坏功能的操作明确提示;
- 压缩完成后提供功能检查结果,而不只展示一个压缩率。
竞品调研重点对比了在线工具、桌面软件、Ghostscript 和商业 PDF 软件。基于本轮调研材料,压缩后逐项报告功能变化是 pdfintact 希望突出的差异点;具体产品能力仍应以各软件当前版本和实际测试为准。
| 能力维度 | 在线压缩工具 | Ghostscript | 商业 PDF 软件 | pdfintact |
|---|---|---|---|---|
| 本地离线处理 | 通常需要上传 | 支持 | 通常支持 | 支持 |
| 压缩后功能检查清单 | 调研中未见普遍提供 | 未见统一报告 | 视产品而定 | 提供(5 项) |
| 原语级自定义 | 少见 | 部分支持 | 视产品而定 | 支持 |
| CLI / HTTP / 浏览器前端 | 通常以网页为主 | CLI | 通常以 GUI 为主 | 三种入口共用同一核心逻辑 |
| 目标体积压缩 | 少见 | 可通过参数实现 | 视产品而定 | 支持质量参数二分搜索逼近 |
表 1:能力维度概览,依据项目调研材料整理,不代表对所有产品版本的穷尽测试。
2. 先定边界,再搭工程
这次开发没有从界面或某个压缩算法开始,而是先逐步确定技术方案、编程语言、框架与库、架构模式。最终方案如下:
- 形态:本地 Python 服务 + 浏览器前端 + CLI,不上传待处理 PDF;
- 后端:Python 3.12;
- PDF 与图像处理:
pikepdf、fontTools、Pillow、pypdfium2、pypdf; - 服务与命令行:FastAPI + Uvicorn,Typer;
- 前端:Vue 3.5 + Vite 8 + TypeScript + Naive UI 2.45 + Pinia 4 + vue-router 4;
- 架构:分层与端口适配器,核心业务不依赖 HTTP 或 CLI 协议。
项目把压缩、页面操作和历史记录放在 src/pdfintact/ 核心包中;src/app/ 是适配层,其中 app/http.py 创建 FastAPI 实例并注册路由,app/cli.py 提供命令行入口,app/__main__.py 是服务启动器。这样,核心逻辑可以独立测试,新增入口时也不必复制压缩实现。
浏览器 ──HTTP──▶ app/http.py(FastAPI 适配层) ─┐
├──▶ pdfintact/(核心逻辑,零协议依赖)
命令行 ────────▶ app/cli.py(Typer 适配层) ────┘
核心层有一条明确边界:pdfintact/ 内禁止 import fastapi / typer / uvicorn / pydantic。PDF 领域逻辑留在 core 中,协议和入口细节由适配层处理。
后端依赖以 Python 包为主,前后端分别使用 uv 和 pnpm 管理依赖,并提交锁文件以便复现。下方为项目依赖配置节选:
# pyproject.toml(节选)
dependencies = [
"pikepdf>=8.0",
"fonttools>=4.40",
"Pillow>=10.0",
"pypdfium2>=4.0",
"pypdf>=4.0",
"fastapi>=0.141.1",
"uvicorn>=0.53.0",
"typer>=0.27.2",
"python-multipart>=0.0.32",
"pydantic>=2.0",
]
2.1 码道带来的开发体感
这个项目横跨 Python 后端、Vue 前端、CLI、打包和 PDF 领域知识,冷启动时需要同时搭出多个模块,还要准备设计文档和测试用例。开发过程中的时间估算如下:
| 环节 | 纯手写估时 | 码道辅助后 | 体感提效 |
|---|---|---|---|
| 架构骨架与多模块脚手架 | 4 小时 | 0.5 小时 | 约 8 倍 |
| 竞品调研报告(4 篇) | 2 天 | 0.5 天(起草后校订) | 约 4 倍 |
| 测试脚手架(10 个文件) | 3 小时 | 0.5 小时 | 约 6 倍 |
| 核心算法实现 | 1 天 | 1 天(人工主导) | 约 1 倍 |
这些是项目过程中的估算,不是严格的对照实验。它反映出的体感是:AI 对脚手架、资料初稿和边界用例起草帮助较大;压缩策略是否正确、哪些功能必须保留,仍需要开发者定义并通过真实 PDF 验证。
3. 压缩核心:七个原语,一条带护栏的管线
pdfintact 将压缩拆成 7 个可以独立启用的原语。预设档位是这些原语及其参数的组合,而不是四套互不相干的实现。
| 原语 | 风险 | 主要作用 | 适用场景与注意事项 |
|---|---|---|---|
结构重压 structure | L | 重压对象流、优化内部结构 | 通用;现代 PDF 的收益可能较小 |
重复流去重 dedup_streams | L | 相同对象流复用引用 | 含重复图像或对象的文件 |
元数据清理 clean_metadata | L | 清理冗余元数据和缩略图 | 通用,通常只减少少量体积 |
字体子集化 font_subset | M | 仅保留文档实际使用的字形 | 嵌入完整中文字体时可能收益明显 |
图像重编码 image | M | 降 DPI + JPEG quality 重编码,q≤30 时进入极端降质模式 | 扫描件、教材等图像较多的 PDF |
整页栅格化 rasterize | H | 将整页渲染成图像 | 适合图像占比较高的文件;文本层会丢失,文本型 PDF 可能膨胀 |
字体去嵌入 deembed_fonts | H | 移除嵌入字体,依赖阅读器字体回退 | 仅在确认目标设备有合适字体时考虑 |
表 2:七个压缩原语及风险提示。L / M / H 表示项目中的低、中、高风险等级。原语显示顺序按风险 L→M→H 排列(UI 层),执行顺序按语义依赖排列(管线层),两者故意独立。
3.1 四档预设是参数组合
预设把不同原语和参数组合起来,便于用户快速选择;也保留了自定义原语勾选能力。
# src/pdfintact/compress/presets.py(节选)
LOSSLESS = Preset(
name="无损", structure=True, font_subset=False,
image=ImageParams(enabled=False), clean_metadata=True,
)
BALANCED = Preset(
name="均衡", structure=True, font_subset=True,
image=ImageParams(enabled=True, target_dpi=150, jpeg_quality=80),
clean_metadata=True,
)
EXTREME = Preset(
name="极限", structure=True, font_subset=True,
image=ImageParams(enabled=True, target_dpi=72, jpeg_quality=55),
clean_metadata=True,
)
HYPER = Preset(
name="超极限", structure=True, font_subset=False,
image=ImageParams(enabled=False), # 栅格化会重新渲染整页
rasterize=True, raster_dpi=126, raster_quality=55,
clean_metadata=True,
)
| 档位 | 结构重压 | 重复流去重 | 元数据清理 | 字体子集化 | 图像处理 | 整页栅格化 |
|---|---|---|---|---|---|---|
| 无损 | 开 | 开 | 开 | 关 | 关 | 关 |
| 均衡 | 开 | 开 | 开 | 开 | 150 DPI / q80 | 关 |
| 极限 | 开 | 开 | 开 | 开 | 72 DPI / q55 | 关 |
| 超极限 | 开 | 开 | 开 | 关 | 关 | 126 DPI / q55 |
表 3:四档预设的原语开关。三个 L 级原语始终开启。具体功能保留情况以压缩后的检查结果为准。
3.2 管线护栏与互斥规则
管线统一编排原语,并在执行前后检查文件体积。若普通原语没有减小体积,就回退到上一步;如果某个原语失败,则将原因写入警告,避免单步失败悄悄变成"压缩成功"。栅格化会覆盖文本、字体或图像处理的中间结果,因此管线会跳过互相冲突的步骤并报告原因。
# src/pdfintact/compress/pipeline.py(节选)
_H_PRIMITIVES = {"rasterize", "deembed_fonts"}
_RASTERIZE_CONFLICTS = {"font_subset", "image", "deembed_fonts"}
_DEEMBED_CONFLICTS = {"font_subset"}
for p in self.primitives:
if not p.enabled(ctx.preset):
continue
if rasterize_on and p.name in _RASTERIZE_CONFLICTS:
ctx.report.warnings.append(
f"{p.name}: 因 rasterize 已启用,成果会被丢弃,跳过"
)
continue
if deembed_on and p.name in _DEEMBED_CONFLICTS:
ctx.report.warnings.append(
f"{p.name}: 因 deembed_fonts 已启用,成果会被丢弃,跳过"
)
continue
before_path = ctx.current_path
before_size = before_path.stat().st_size if before_path.exists() else 0
try:
p.run(ctx)
except Exception as error:
ctx.report.warnings.append(f"{p.name}: {error}")
continue
after_path = ctx.current_path
after_size = after_path.stat().st_size if after_path.exists() else 0
skip_guard = p.name in _H_PRIMITIVES
if p.name == "image":
img_params = getattr(ctx.preset, "image", None)
if img_params and img_params.jpeg_quality <= 30:
skip_guard = True # 极端降质模式,跳过体积护栏
if after_path != before_path and after_size >= before_size and not skip_guard:
ctx.current_path = before_path
ctx.report.warnings.append(
f"{p.name}: 处理后体积未减小,跳过该步结果"
)
continue
ctx.report.primitives_run.append(p.name)
管线中有两层互斥规则:
- 栅格化与内容原语互斥:
rasterize开启时,font_subset、image、deembed_fonts成果会被整页位图覆盖,直接跳过并告警。 - 字体去嵌入与字体子集化互斥:
deembed_fonts移除嵌入字体后,font_subset无字体可操作,反之亦然。
体积护栏也很重要:已经高度压缩的 JPEG 再编码一次,结果可能更大;对这类普通压缩步骤,保留上一步通常更合理。
3.3 压缩后的功能自检与目标体积
压缩结束后,verify.py 会比较原 PDF 与输出文件,检查 5 个维度:
- 文字可选中复制:是否仍可提取文本(pypdf 提取检测);
- 书签可跳转:/Outlines 书签树节点数是否保留(pikepdf 递归计数);
- 文字显示正常:是否去嵌入字体(去嵌入则标 at_risk);
- 全文可搜索:文本层是否存在;
- 图像质量:是否启用了图像重编码(启用则标 at_risk)。
检查结果汇总为功能变更清单,随压缩报告返回。对于整页栅格化等有意牺牲文本层的操作,清单会明确标示相关功能已放弃(abandoned),而不是将其包装成无损压缩。
此外,工具支持指定目标体积。target_solver.py 通过二分搜索调整 JPEG quality,多次运行图像重编码以逼近目标字节数;目标是否可达取决于 PDF 内容和其他约束,因此这是逼近而非绝对保证。
4. 三种入口,共用一套核心
前端使用 Vue 3.5、Naive UI 2.45 和 Pinia 4,主要面板覆盖压缩、页面操作和历史记录。开发时用 pnpm dev 热更新,生产构建输出到 frontend/dist/,由 FastAPI 静态文件中间件提供。
后端的 app/http.py 是 FastAPI 薄层,路由按域拆为 app/routes/compress.py、app/routes/pages.py、app/routes/history.py,共 16 个 API 端点:
- 压缩域 4 个:
GET /api/presets、POST /api/compress、POST /api/compress/batch、GET /api/download/{output_id}; - 页面操作域 10 个:合并、拆分、旋转、提取、删除、N-up 拼版、小册子、水印、水印删除、页码;
- 历史域 2 个:
GET /api/history、DELETE /api/history。
上传会限制文件大小并检查 %PDF- 文件头;产物采用 TTL 清理策略,避免临时输出长期堆积。
CLI 由 Typer 提供,覆盖压缩、目标体积、批量任务、页面操作、历史记录和预设查询。CLI 与 HTTP 调用同一套 core 逻辑,包括预设和原语选择的解析,不维护两份压缩实现。
def parse_selection_json(raw: str | None) -> PrimitivesSelection | None:
"""解析 CLI / HTTP 共用的原语勾选清单。"""
import json
if not raw:
return None
return PrimitivesSelection(**json.loads(raw))
具体使用时,HTTP、CLI 和浏览器前端分别承担适合自己的交互方式,PDF 处理规则仍收敛在核心层。
5. 实测:压缩率之外,也看功能是否保留
项目使用 tests/fixtures/ 中的 5 份 PDF 测试四个档位,记录压缩体积、耗时和功能变化。下面先看 70 页中文教材的结果:
| 档位 | 原体积 | 压缩后 | 压缩率 | 耗时 | 文本可选 | 书签可跳 | 全文搜索 |
|---|---|---|---|---|---|---|---|
| 无损 | 21.57 MB | 21.42 MB | 0.70% | 2.73 秒 | 保留 | 保留 | 保留 |
| 均衡 | 21.57 MB | 20.96 MB | 2.82% | 5.91 秒 | 保留 | 保留 | 保留 |
| 极限 | 21.57 MB | 17.26 MB | 19.99% | 6.05 秒 | 保留 | 保留 | 保留 |
| 超极限 | 21.57 MB | 5.41 MB | 74.90% | 3.78 秒 | 放弃 | 放弃 | 放弃 |
表 4:70 页中文教材四档实测,数据记录于 2026-09-28。超极限档通过栅格化换取体积下降,因此文本、书签和搜索不再保留。
将测试扩展到 5 份语料后,结果如下:
| 语料 | 页数 | 无损 | 均衡 | 极限 | 超极限 |
|---|---|---|---|---|---|
| 中文教材(图像型) | 70 | 0.70% | 2.82% | 19.99% | 74.90% |
| 纯文本pdf | 8 | 0.77% | 16.19% | 28.69% | 23.52% |
| 上海交大生存手册pdf | 124 | 7.05% | 7.05% | 7.05% | -804.30% |
| 海康威视校招 Q&A | 4 | 4.17% | 8.42% | 40.93% | 5.09% |
| 七上数学 | 7 | 6.52% | 6.52% | 6.52% | -341.04% |
表 5:5 份语料各档位实测压缩率。负值表示输出文件比原文件更大。
负值最能说明问题:《上海交通大学生存手册》从 2.28 MB 增至 20.64 MB,膨胀 804.30%;《七上数学》从 0.17 MB 增至 0.75 MB,膨胀 341.04%。这两份内容以文本或矢量页面为主,整页栅格化把紧凑的文字和矢量对象转换为整页位图,页面信息反而需要更多空间。
所以,档位更激进不代表结果一定更小。在图像占比较高的文档上,栅格化可能非常有效;在文本型 PDF 上,则可能适得其反。预设、警告和压缩后自检需要配合使用,不能只看档位名称判断结果。
6. 六个真实踩坑
坑 1:DCTDecode 图像被静默跳过
现象:某些 PDF 的压缩率长期停在个位数,没有明显报错。
原因:PDF 中常见的 JPEG 图像使用 DCTDecode。直接调用 pikepdf 的 read_bytes() 读取这类不可过滤流会失败;如果异常被宽泛捕获后静默跳过,大批图像就完全没有进入重编码流程。
处理:按 /Filter 分流。JPEG 流通过 read_raw_bytes() 读取原始数据,再交给 Pillow 解码;其他可解码流走 read_bytes() 路径。两个图像原语共用解码逻辑,并为失败路径补充日志。修复后,图像处理才实际覆盖了原先遗漏的 JPEG 场景。
坑 2:CCITT G4 参数不完整导致花屏
现象:输出 PDF 打开后出现花屏或整页发黑。
原因:CCITT G4 图像流的 /DecodeParms 缺少 /Columns、/Rows 等参数,黑白极性也可能配置错误;此外,Pillow 的 TIFF strip 数据不能直接当作 PDF 图像流使用。
处理:当前实现对二值图像采用 FlateDecode(zlib 压缩位图)路径,避免不匹配的 G4 参数造成阅读器解码异常。
坑 3:Windows 临时字体文件被占用
现象:清理临时 .ttf 时出现 WinError 32;更隐蔽的是,清理阶段的异常可能覆盖前面已经成功的子集化结果。
原因:fontTools 的 TTFont 默认懒加载,可能继续持有临时文件句柄;如果 finally 中的清理异常没有处理,就会影响函数最终结果。
处理:使用 lazy=False 及时读入字体,完成后显式关闭句柄;临时文件删除失败时记录日志,不让清理异常覆盖主要处理结果。
font = subset.load_font(str(in_path), options, lazy=False)
try:
sub = subset.Subsetter(options)
sub.populate(unicodes=unicodes)
sub.subset(font)
subset.save_font(font, str(out_path), options)
result = out_path.read_bytes()
finally:
try:
font.close()
except Exception:
log.debug("字体句柄关闭失败", exc_info=True)
for temporary_path in (in_path, out_path):
try:
temporary_path.unlink(missing_ok=True)
except OSError:
log.debug("临时字体文件清理失败: %s", temporary_path)
坑 4:PyInstaller 下 __file__ 指向临时解包目录
现象:开发环境运行正常,打包后日志和持久化资源路径混乱。
原因:PyInstaller 运行时会将部分资源解压到 _MEIxxxxxx 临时目录,模块的 __file__ 可能指向该目录。写入其中的日志可能在程序退出后消失。
处理:区分打包与开发模式。打包时用 sys.executable 所在目录定位应用持久化文件;开发时再使用项目目录。路径规则集中放在配置模块中管理。
坑 5:静默打包后浏览器没有启动
现象:设置 console=False 后服务已经启动,但浏览器没有弹出,也没有控制台可供排查。
原因:静默模式下,webbrowser.open 或 os.startfile 可能无法按预期打开浏览器;如果没有日志,启动失败就很难定位。
处理:为浏览器启动增加多策略回退,并将启动状态和错误写入日志。这样即使自动打开失败,也能确认服务是否启动以及失败发生在哪一步。
坑 6:整页栅格化让文本型 PDF 体积暴涨
现象:小体积的文本或矢量 PDF 经超极限压缩后,输出反而变大数倍。
原因:栅格化将每一页渲染成位图。对于原本由紧凑文字和矢量对象构成的页面,位图表示可能远大于原始内容。
处理与结论:栅格化保持为高风险的可选操作;压缩后检查实际文件大小并显示功能变化。后续还应在服务端增加压缩前风险探测,提醒用户文本型 PDF 可能不适合该档位。
其他工程问题
开发期间还处理了几类常见问题:图像处理中的静默 except 补充诊断日志;清理 Vue 脚手架残留;下载产物由模糊 glob 匹配改为精确后缀匹配;通过元数据保存上传文件原名;版本号从硬编码改为读取包元数据;统一开发与打包模式下的日志路径。
这些问题背后的共同教训是:捕获异常不等于处理异常。失败若没有日志、返回状态或测试,用户看到的往往只是"压缩效果不好",真正原因却被藏起来。
7. 从能跑到能交付
7.1 测试与覆盖
项目包含 10 个测试文件、225 个测试用例,覆盖压缩预设、字体子集化、图像重编码、PDF 边界、页面操作、批量任务、历史记录和安全边界。项目记录的测试结果为:
225 passed in 15.44s
TOTAL 2399 1714 29%
其中字体和图像处理用例较密集,因为这两个领域涉及空字体、无效字节、异常图像流和写回失败等多种边界情况。测试数字描述的是项目当时的运行结果,发布前建议在最终代码版本上重新执行并更新。
7.2 输入校验、产物清理与打包
HTTP 上传接口会限制文件大小,并检查 %PDF- 文件头,减少非 PDF 文件进入处理流程的情况。临时产物按 TTL 策略清理;下载时使用保存的原始文件名元数据恢复可读名称。
项目还准备了 Docker 配置、CI 工作流和 PyInstaller 打包配置。PyInstaller 可生成 pdfintact.exe,目标是让没有 Python 环境的用户也能启动本地服务。开发环境启动方式如下:
uv sync
cd frontend
pnpm install
pnpm build
cd ..
uv run python -m app
CLI 示例:
# 均衡档压缩
uv run python -m app.cli compress in.pdf -o out.pdf -p 均衡
# 指定目标体积(字节)
uv run python -m app.cli compress in.pdf -o out.pdf -t 1048576
# 批量压缩
uv run python -m app.cli compress a.pdf b.pdf c.pdf -o outdir/ -p 极限 --concurrency 4
7.3 文档与调研
项目开发过程中沉淀了竞品调研、压缩策略、原语设计和工程实现等专题文档。调研帮助梳理了现有工具的能力边界;最终产品的差异化重点,则落在"压缩结果之外,再检查并报告功能变化"这一工作流上。
8. 复盘与后续计划
回看整个开发过程,AI 的帮助并不平均:架构骨架、脚手架、调研初稿和测试边界用例适合由 AI 加速起步;核心算法、互斥关系和功能保留标准则需要人来定义,并通过真实 PDF、日志和测试验证。真正从"能运行"走到"可靠",仍离不开对业务正确性的持续检查。
目前仍有一个明确的改进项:服务端尚未针对"超极限 + 文本型 PDF"建立硬性风险拦截。下一步可以在压缩前估算页面的文本与图像占比,对反增风险给出警告,或建议用户改用其他档位。
后续计划包括:
- 增加 JBIG2、MRC 等图像压缩方案;
- 增加文本 / 图像占比探测与反增风险提示;
- 支持 PDF/A 归档格式;
- 完善前端拖拽排序和批量进度展示;
- 探索 ArkTS + ArkWeb 形态的鸿蒙端。鸿蒙端目前仍是计划,尚未实现。
对这个项目来说,最重要的实测结果不只有 74.90%,也包括 -804.30%。前者说明特定图像型 PDF 可以显著减小;后者提醒我们,压缩策略必须结合文档内容,并且要把用户失去的功能如实说明。
复现方式:
uv sync
cd frontend
pnpm install
pnpm build
cd ..
uv run python -m app
AtomGit 仓库:https://atomgit.com/CYXue/pdfintact
如果你也在做 PDF 工具,或关注 AI 辅助全栈开发中的工程化实践,欢迎在 AtomGit 上交流。
更多推荐




所有评论(0)