用华为云码道从 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 个可以独立启用的原语。预设档位是这些原语及其参数的组合,而不是四套互不相干的实现。

原语风险主要作用适用场景与注意事项
结构重压 structureL重压对象流、优化内部结构通用;现代 PDF 的收益可能较小
重复流去重 dedup_streamsL相同对象流复用引用含重复图像或对象的文件
元数据清理 clean_metadataL清理冗余元数据和缩略图通用,通常只减少少量体积
字体子集化 font_subsetM仅保留文档实际使用的字形嵌入完整中文字体时可能收益明显
图像重编码 imageM降 DPI + JPEG quality 重编码,q≤30 时进入极端降质模式扫描件、教材等图像较多的 PDF
整页栅格化 rasterizeH将整页渲染成图像适合图像占比较高的文件;文本层会丢失,文本型 PDF 可能膨胀
字体去嵌入 deembed_fontsH移除嵌入字体,依赖阅读器字体回退仅在确认目标设备有合适字体时考虑

表 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)

管线中有两层互斥规则:

  1. 栅格化与内容原语互斥:rasterize 开启时,font_subset、image、deembed_fonts 成果会被整页位图覆盖,直接跳过并告警。
  2. 字体去嵌入与字体子集化互斥: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 MB21.42 MB0.70%2.73 秒保留保留保留
均衡21.57 MB20.96 MB2.82%5.91 秒保留保留保留
极限21.57 MB17.26 MB19.99%6.05 秒保留保留保留
超极限21.57 MB5.41 MB74.90%3.78 秒放弃放弃放弃

表 4:70 页中文教材四档实测,数据记录于 2026-09-28。超极限档通过栅格化换取体积下降,因此文本、书签和搜索不再保留。

将测试扩展到 5 份语料后,结果如下:

语料页数无损均衡极限超极限
中文教材(图像型)700.70%2.82%19.99%74.90%
纯文本pdf80.77%16.19%28.69%23.52%
上海交大生存手册pdf1247.05%7.05%7.05%-804.30%
海康威视校招 Q&A44.17%8.42%40.93%5.09%
七上数学76.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 上交流。

Logo

一站式 AI 云服务平台

更多推荐