ARTICLE · 1095568
OFD 转 PDF 和 PDF 转 OFD,为什么完全不是一道题?
做 OFD 转换的人常犯一个错:把两个方向放在同一张榜单里比。
"go-zc310 在 OFD→PDF 里文本相似度拿了 4 个第一" —— 听起来很厉害。 "rust-easyofd 在 PDF→OFD 里速度和体积各拿 7 个第一" —— 听起来更厉害。
但如果你真把两边的输出文件打开看一眼,会发现:
前者的强项是真的强; 后者的"第一",页面上是空的。
这不是谁更努力,是两个方向的数学难度根本不对等。
⚠️ 阅读前声明:本系列所有数字来自 ofd-benchmark 的一次实测,只对特定库版本成立(版本快照见 第 〇 节)。OFD 库多为 0.x 早期项目,新版很可能修掉本文提到的若干短板 —— 尤其是本文批评最狠的 rust-easyofd(PDF→OFD 输出空页),那属于"实现缺失"而非"设计取舍",是会被修掉的。
本文真正不过期的部分是第 2 节的四条技术路线和第 4 节的指标解读 —— 那是结构和数学,不是某个版本的实现状态。
一、先把不对称说清楚
1.1 OFD → PDF:换一种语言表达已知版式
OFD 的绘制语义是 显式声明 的。你打开一个 .ofd,解压成 ZIP,读 Document.xml,会明确看到:
这个页面是 210mm × 297mm这一段是 PathObject,d 属性是 "M 10 10 L 100 10 ...",FillColor 是 CMYK这行字是 TextObject,TextCode 是 [72 101 108 108 111],字体是 Font_1,字号 3.5这个引用了一张 Res_Image_2.png翻译成 PDF,就是把这些声明换成另一套表达:
PathObject/@d | m/l/c/re) |
TextObject/@TextCode | ToUnicode 映射 |
TextObject/@CGTransform | Tm |
ImageObjectRes_Image_*.png | |
CTM | cm |
每一项都有确定的对应关系。 转换器面对的是一份结构清晰的说明书,工作量在于"每一种对象都实现对",而不在于"猜出原本是什么"。
所以实测里出现了这样的局面:8 个 OFD→PDF 转换器里,Go×2、Rust、Java、Node 这 5 个全部 6/6 成功,页面尺寸全是 A4、矢量图形全部支持;python-easyofd 和 python-ofd2pdf 各失败 2 次(成功率 67%),其中 python-ofd2pdf 的矢量图形明确标为不支持;python-ofdreader 因模块未安装 6/6 全部 FAILED(源文档已将其排除在质量总结表外)。成功率 100% 是主流库的配置,不是加分项。
1.2 PDF → OFD:逆向推断"原本是什么"
PDF 是 表现格式,不是描述格式。它只记录"画完长什么样",不记录"为什么这么画"。
看一个真实的 PDF 页面对象,它能给你的只有:
内容流:q ... cm BT /F1 12 Tf 72 720 Td (Hello) Tj ET ... Q资源字典:/F1 → 一个嵌入字体子集从这行指令里,你能确定的是:这里画了若干个 glyph,位置在 72,720。
你无法确定的是:
✗ 这段文字在源文档里是「一个 TextCode 对象」还是「20 张单字图片」✗ 这个 12pt 是「字号 3.5mm」还是「CSS 里的 11px」✗ 标题和正文是「两种 TextObject 用了不同字号」还是「同一种样式被整体缩放」✗ 那个蓝色方块是「一个 PathObject」还是「一张纯色 PNG」✗ 这行字原本是横排还是竖排(PDF 只记最终矩阵)PDF 的内容流没有语义标签。它是一串"把画笔移到这、画条线、填个色"的指令,把所有东西 —— 标题、正文、表格线、图标、签章 —— 都压成了同一种东西:绘制操作。
所以 PDF→OFD 的转换器必须在"还原外观"和"还原结构"之间做选择,而这两件事在数学上互斥:
保外观(轮廓/位图) → 外观可复制,文本不可选、不可搜、不可抽保结构(路径/文本) → 结构可搜索,但字距/换行/对齐可能有偏差实测数据把这条互斥关系演示得非常干净(PDF→OFD 方向,从输出 OFD 里提取的字符数):
| 420998 | ||
| 534998 | ||
| 510996 | ||
| 0 | ||
| 0 | ||
保外观的那一列,字符数是 0。 不是"提取失败",是文件里压根没有文本对象可提取。
二、四条技术路线,一张图看懂
把两边的实现归一化,业界实际只有四条路:
┌─ 路线 A:矢量重绘(保结构)─────────────────────────────┐│ 读路径/文字/图片对象 → 换算坐标系 → 输出目标格式的对象 ││ OFD→PDF: go-zc310 / go-ofdgo / rust-easyofd / ││ java-ofdrw / node-ofd2pdf ││ ⚠️ python-easyofd 归属存疑:源文档只写 ││ 「pip install easyofd ofd2img,自动切换库」, ││ 无法判定它是矢量重绘还是渲染,可能按文件混用 ││ PDF→OFD: go-zc310(pdfcpu) / go-ofdgo(pdfgo) / ││ node-jsofd(pdfjs) ││ 优点:体积可控、文本可选可搜、矢量无损放大 ││ 缺点:坐标/字距/填充规则对齐难,复杂页面易掉细节 │└────────────────────────────────────────────────────────┘┌─ 路线 B:轮廓化(保外观,丢文本)──────────────────────┐│ 文字 → 字形轮廓路径(转曲) ││ PDF→OFD: java-ofdrw ││ 优点:外观与原 PDF 高度一致,字体不需要额外处理 ││ 缺点:文本彻底丢失;体积暴涨(实测 10~26 倍) │└────────────────────────────────────────────────────────┘┌─ 路线 C:栅格化(保外观,丢一切)──────────────────────┐│ 整页渲染成位图 → 贴进目标格式 ││ OFD→PDF: python-ofd2pdf ││ PDF→OFD: python-pdf2ofd(PyMuPDF) ││ 优点:实现极简,"画得像"几乎不会错 ││ 缺点:无文本、无矢量、体积大、放大即糊 │└────────────────────────────────────────────────────────┘┌─ 路线 D:抽取式(只拿文本/图片)───────────────────────┐│ 不处理矢量图形,只抽文本与图片对象 ││ PDF→OFD: rust-easyofd(lopdf) ││ 优点:速度极快 ││ 缺点:图形全丢;CID 编码易乱码;版面退化成固定坐标 │└────────────────────────────────────────────────────────┘选型 = 选路线。 后面 02、03 两篇的所有分数,都可以由这条路线解释。
三、样本选得对,数据才有意义
ofd-benchmark 双向测试用的是同一组文档,这是全系列最值得学的一点设计。
OFD 源文件 │ ┌──────────┴──────────┐ ▼ ▼ OFD→PDF 转换 数科版式阅读器 (8 个库) (商用参考实现) │ │ ▼ │ bin/output/*.pdf │ │ 像素对比 ◀─────────┘ │ 文本对比 ◀─────────┘ │ │(参考 PDF 同时也是 PDF→OFD 的输入) ▼ PDF→OFD 转换(6 个库) │ ▼ bin/output_ofd/*.ofd │ 回环:统一用 go-zc310 转回 PDF ▼ 与源 PDF 做像素/文本对比这样带来两个好处:
- OFD→PDF 有客观锚点
—— 不需要"我转得对不对"的自证,直接和商用阅读器导出的结果比。 - 双向可比
—— 同一个 intro文档,OFD→PDF 的失真和 PDF→OFD 的失真可以放在同一张表里讨论。
测试样本覆盖了真实世界的难度分布:
hello | ||
999 | ||
zsbk | 签章文件 | |
ano | 发票 | |
intro | ||
1000-pages | ||
GBT_33190-2016 |
hello 能过,不代表 intro 能过。这是版式转换最常见的误判。
四、指标怎么读:三个数,各盯一件事
4.1 速度 —— 最没用但最好比的指标
hello.ofd → PDF: rust-easyofd 45ms go-ofdgo 102ms go-zc310 125ms node-ofd2pdf 287ms ofdrw 507ms11 倍的差距(45ms vs 507ms),看起来 rust 完胜。
但同一栏里 1000-pages.ofd 拉开到 43 倍(951ms vs 41.29s),而 PDF→OFD 方向的 java-ofdrw 在 1000 页上要 25.90s、python-pdf2ofd 要 34.24s。速度差异在多页场景才成倍暴露,单页测试几乎没有信息量。
更要命的是:速度可以被"什么都不做"刷出来。
PDF→OFD 方向,rust-easyofd 全场最快(4.1ms~274ms,7 个样本全胜),但:
hello → 输出空页(Layer 为空)ano → 输出空页intro → 像素相似度 43.84%它"快",是因为它几乎没提取任何内容。 这是基准测试里最经典的"假第一名"。
4.2 像素相似度 —— 必要不充分
定义:96 DPI 渲染两页,算 1 - 平均归一化色差100% = 完全相同它的致命弱点:对内容稀疏的页面区分度有限。
看 PDF→OFD 的这一行:
rust-easyofd 对 GBT_33190-2016.pdf 像素相似度 96.28%看起来很好对吧?而同一样本它的文本提取是 0 字符。
为什么?因为这份 132 页国标的页面大多是"白底 + 少量图表 + 大量留白",空页和白底页的色差天然接近 0。像素指标在稀疏页面上,会系统性地奖励"什么都没转"的方案。
像素相似度只能证明"没画错",不能证明"该画的都画了"。 必须配一个"画够了没有"的指标 —— 那就是文本提取。
4.3 文本提取与文本相似度 —— 真正区分方案的那一项
文本提取:从输出 PDF/OFD 里能提取多少字符文本相似度:4-gram Jaccard,与参考文本比,100% = 完全一致两个数要一起看,因为它们会背离:
- 字符数多但相似度低
= 提取了,但顺序/字距/切分不对( node-jsofd在 1000-pages 提取 510996 字符,相似度只有 5.49%); - 字符数少但相似度高
= 提取了但漏了内容( go-zc310在 1000-pages 提取 420998,参考是 323700,多了但 4-gram 相似度 8.25%,因为它给字形之间插了空格,H e l l o变成 5 个 token)。
那个空格问题尤其值得展开。ofd-benchmark 的说明里写得很清楚:
go-zc310 保留文本对象,全部 6 个文件均可提取,文本顺序正确(如「欢迎使用」顺序无误),但字形间插入空格(如
H e l l o)使 4-gram 相似度偏低,内容本身完整。
对比 go-ofdgo:提取量更大(534998 字符),但 GBT 样本相似度只有 1.08%,说明顺序和字距错得更厉害。
字符多 ≠ 对字符 0 = 确定错字符少 + 顺序对 = 可能是对的4.4 体积比 —— 别被 0.00 骗了
PDF→OFD 体积比(输出 OFD / 源 PDF): rust-easyofd 0.33 0.00 0.04 0.78 0.57 0.20 0.41 ← 7 胜 go-zc310 0.46 0.43 0.59 1.08 0.93 1.43 1.34 java-ofdrw 1.48 1.00 10.24 5.01 12.07 26.48 6.17ano 那一列 0.00 是什么?源 PDF 904K,输出 1.8K。
因为输出是空页。 1.8K 里没有原文内容。
真实的压缩能力看 go-zc310:7 个样本全部落在 0.43~1.43(中位数 0.93),没有一个超过 1.5 倍,其中 4 个(hello/ano/999/zsbk)转换后比源 PDF 还小。这才是压缩 —— 前提是内容全转了(OFD 内部 XML + PNG 资源经 ZIP 压缩,且 OFD 没有 PDF 那套增量更新和对象间接表的开销)。
而 java-ofdrw 10~26 倍膨胀,是因为它把每个字形都展开成轮廓路径 —— 文本信息换成了几何信息,几何信息的体积天然大一个量级。
五、签章与发票:两个方向各有各的坑
5.1 OFD→PDF:签章是"外观"还是"签名"?
样本里的 zsbk(数科版式签章)、ano(发票)都带签章。转换后:
像素层面 大多数库都 ≥94%(签章外观画出来了)语义层面 转换后的 PDF 不再是"签过名的文件"关键认知:签章外观能复制,签名的密码学意义不能复制。
OFD 的数字签名是 Signatures 清单 + 对 References 的摘要,PDF 是修订/签名字典模型。跨格式搬运时,最多做到"把章的样子画上去"。原始签名值、证书链、时间戳这些证据链,在格式转换中通常是丢失的。
这不意味着转换做错了。但意味着:别把"转换后还能看到红章"当成"转换后仍然可信"。信任链要单独处理。
5.2 PDF→OFD:注解(Annot)是最容易丢的东西
node-jsofd 在 ano(发票)上像素相似度只有 90.23% —— 除了 rust-easyofd 在 intro 上的 43.84% 这种整表崩掉的情况,这是唯一一处"其余项都在 98% 以上、偏偏这项掉到 90%"的掉队。原因写得很具体:
逐条重放绘制指令流(路径/文本/图片),不转换注解/渐变/图案/Type3/剪裁。
ano因 Stamp 丢失像素仅 90.23%。
PDF 的 Stamp 注解(发票的骑缝章、电子章就常以注解形式存在)不在页面内容流里,而在页面 Annots 数组里。不主动读 Annots 的转换器,画面上就是少一块。
go-zc310 走 pdfcpu 路线,路径里明确包含了注解,所以它在 ano 上的像素是 99.75%。
同一份发票,一个 99.75%,一个 90.23%。差别就在"有没有读注解数组"。
六、本文的边界
必须说清楚几件事:
本文的所有数字来自 ofd-benchmark的 单次运行,无方差、无置信区间,无法判断差异是否显著。- 未记录机器规格
(CPU 型号/频率/核数/内存/是否 SSD),绝对耗时不可移植。 Java/Node 未预热,JIT 未达稳态,首次运行吃亏 —— "'Java 最慢'"的结论要谨慎。 没有冷启动/热启动分离,单次测量包含类加载、字体扫描等开销。 - 只有相对排名有意义
,绝对毫秒数在不同机器上会完全不同。
原文档里那句提醒值得原样抄下来:
建议:手工核对输出 PDF 效果,选择合适的库。不同 OFD 文件结构差异较大,实际效果可能与基准测试结果不同。
七、一句话收束
OFD → PDF 是「翻译」:原文语义还在,换套表达,难在全面覆盖PDF → OFD 是「考古」:只有像素和字形,难在猜回结构先承认这个不对称,再去看榜单 —— 否则你会把"空页面 4ms"读成"性能冠军"。
参考资料
ofd-benchmark/docs/ofd-to-pdf.md(OFD→PDF 全部数据) ofd-benchmark/docs/pdf-to-ofd.md(PDF→OFD 全部数据) ofd-benchmark/docs/ofd-libraries.md(66 个库能力表) ofd-benchmark/AGENTS.md(技术路线与已知问题的工程记录) GB/T 33190—2016(OFD 结构)· ISO 32000(PDF 结构)