夜雨聆风学习资料网

ARTICLE · 1095568

OFD 转 PDF 和 PDF 转 OFD,为什么完全不是一道题?

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,就是把这些声明换成另一套表达:

OFD 里的声明
PDF 里的对应物
PathObject/@d
 紧缩路径
PDF 路径构造算子(m/l/c/re)
TextObject/@TextCode
字体 + 编码 + ToUnicode 映射
TextObject/@CGTransform
文本矩阵 Tm
ImageObject
 + Res_Image_*.png
XObject 图像
CTM
 六参数变换
cm
 变换矩阵
页面空间 mm
PDF 用户空间 pt(需 72/25.4 换算)

每一项都有确定的对应关系。 转换器面对的是一份结构清晰的说明书,工作量在于"每一种对象都实现对",而不在于"猜出原本是什么"。

所以实测里出现了这样的局面: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 里提取的字符数):

转换器
技术路线
1000-pages 提取字符数(参考 323700)
go-zc310
路径+文本重放
420998
 ✅
go-ofdgo
路径+文本重放
534998
 ✅
node-jsofd
pdfjs 指令流重放
510996
 ✅
java-ofdrw
文本 → 矢量轮廓
0
 ❌
python-pdf2ofd
栅格渲染成位图
0
 ❌
rust-easyofd
只抽文本/图片,不转矢量
10996(CID 乱码)❌

保外观的那一列,字符数是 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 做像素/文本对比

这样带来两个好处:

  1. OFD→PDF 有客观锚点
     —— 不需要"我转得对不对"的自证,直接和商用阅读器导出的结果比。
  2. 双向可比
     —— 同一个 intro 文档,OFD→PDF 的失真和 PDF→OFD 的失真可以放在同一张表里讨论。

测试样本覆盖了真实世界的难度分布:

样本
页数
难度来源
hello
1
最小示例,只测"能不能跑通"
999
5
简单多页文档
zsbk
2
签章文件
(数科版式签章)
ano
3
发票
(Stamp 注解、复杂版面)
intro
42
复杂图形、背景图、多层结构
1000-pages
1000
大页数、字体子集复用
GBT_33190-2016
132
国标正文,字体全内嵌、图表多

hello 能过,不代表 intro 能过。这是版式转换最常见的误判。


四、指标怎么读:三个数,各盯一件事

4.1 速度 —— 最没用但最好比的指标

hello.ofd → PDF:  rust-easyofd    45ms  go-ofdgo       102ms  go-zc310       125ms  node-ofd2pdf   287ms  ofdrw          507ms

11 倍的差距(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.17

ano 那一列 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%。差别就在"有没有读注解数组"。


六、本文的边界

必须说清楚几件事:

  1. 本文的所有数字来自 ofd-benchmark 的 单次运行,无方差、无置信区间,无法判断差异是否显著。
  2. 未记录机器规格
    (CPU 型号/频率/核数/内存/是否 SSD),绝对耗时不可移植。
  3. Java/Node 未预热,JIT 未达稳态,首次运行吃亏 —— "'Java 最慢'"的结论要谨慎。
  4. 没有冷启动/热启动分离,单次测量包含类加载、字体扫描等开销。
  5. 只有相对排名有意义
    ,绝对毫秒数在不同机器上会完全不同。

原文档里那句提醒值得原样抄下来:

建议:手工核对输出 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 结构)

相关学习资料