做企业端开发的朋友应该都有这种体会:业务系统里"版本式文档"这件事,看着简单,做起来全是规则。我最近接手的一个项目,上游系统给的全是PDF,下游归档和电子签章环节却明确要求OFD格式,还指定要用SM2算法对OFD做签名。最初我也觉得这东西不好搞,但把ofdrw集成进SpringBoot之后,才发现PDF和OFD互转、SM2签署OFD这套链路,其实是可以完整走通的,关键是把原理和坑都摸清楚。
这篇实战记录就围绕三条主线展开:为什么要在SpringBoot里集成ofdrw而不是自己造轮子;PDF转OFD、OFD转PDF这两个方向的实现代码和注意事项;以及用SM2算法给OFD签署电子签名的完整流程。内容全部来自我的实际集成过程,代码可以抄,坑也可以少踩。
1. 整体设计:为什么要选 ofdrw,它的模块又该怎么拆
1.1 三个核心需求,本质分别对应什么
先把需求拆开看。PDF转OFD,表面上是个"格式转换",实际上做的是版式数据重建。PDF和OFD虽然是两种不同的版式文档规范,但它们的底层逻辑有相通之处:都描述页面大小、文字位置、图片坐标、矢量路径这些元素。转换工具要做的不是改文件后缀,而是把PDF里的内容元素提取出来,再按OFD的规范重新组织成一个包结构。这就是为什么有些转换工具转出来的文件,看起来"内容都对",但打开之后文字不能选、结构乱七八糟,因为它只做了粗糙的映射,没有真正解析内容流。
OFD转PDF则是反向操作。为什么需要反向?因为现在很多业务系统的下游还是以PDF为默认预览格式,浏览器、移动端、第三方平台对OFD的原生支持还不够普及。所以OFD文件在归档之后,经常还得转回PDF用于展示、下载和打印。这同样不是简单的格式互换,而是要把OFD包中的页面描述、字体资源、图片资源重新还原到PDF的页面模型里。
第三件事,SM2签署OFD,解决的是可信归档问题。OFD作为一种面向电子文件交换和长期保存的版式格式,支持在文件包内嵌入数字签名数据。SM2算法是我国密码算法标准体系里的非对称算法,常用于数字签名和密钥交换,实际签署时通常会配合SM3摘要算法一起使用。签名后的OFD文件,任何人对内容做了修改,验签都会失败,这正好满足电子凭证、电子回单这类场景的防篡改要求。
1.2 选型分析:自研、商业SDK、开源方案怎么权衡
我在动手之前,先给自己列了三个可选路径。
第一个路径是自研。PDF解析、OFD生成、坐标系变换、字体嵌入、国密签名,每一块都是完整的技术栈,光是把GB/T 33190这套OFD规范读透就需要不少时间,更别说还要处理各种边界情况。自研的可靠性和周期风险都太高,对小团队来说不划算。
第二个路径是商业SDK。市场上确实有成熟的商业版式软件,功能强、服务好,但价格不低,而且很多商业SDK在私有化部署、许可证授权上有不少限制,和SpringBoot项目的集成方式也不一定灵活。如果项目预算充足、有合规认证要求,那可以考虑,但对我们这个场景来说,没必要一上来就上重武器。
第三个路径就是开源的ofdrw。它是Java语言实现的OFD处理库,Apache License 2.0协议,可以自由用在商业项目里。我重点看中的是三点:一是模块化做得比较清楚,转换、签名、解析、生成都是独立模块,按需引入;二是它对国密算法有原生支持,SM2签名这部分不用自己再对接密码库;三是社区活跃度还行,遇到问题能在issue里找到答案。综合下来,我选择了ofdrw。
1.3 ofdrw模块结构和SpringBoot里的整体架构
ofdrw的模块划分值得先搞清楚,不然依赖全引进去,又是一堆没必要的冲突。
| 模块 | 作用 | 典型场景 |
|---|---|---|
| ofdrw-core | 基础数据结构、文档模型 | 所有模块的依赖基础 |
| ofdrw-reader | 解析已有OFD文件 | 读取OFD内容、检查文档结构 |
| ofdrw-converter | PDF转OFD、OFD转PDF/图片 | 本文两个转换需求都用它 |
| ofdrw-publisher | 从零生成OFD文件 | 手工排版生成OFD |
| ofdrw-sign | OFD签名与验签 | SM2签署OFD、签名校验 |
| ofdrw-validator | 按规范校验OFD | 签名后结构自检 |
在SpringBoot里,我采用的是Controller接请求、Service调工具层的结构。Controller负责接收文件路径或者MultipartFile,Service层封装转换和签名逻辑,底层调用ofdrw的API。这样做的原因是业务系统后面可能还会接入自动归档、批量加签、定时验签这些功能,如果为了快直接把ofdrw的调用散落在Controller里,后期维护会很难受。
2. 环境准备与SpringBoot集成前的关键配置
2.1 Maven依赖怎么加
确定用ofdrw之后,第一步就是把它加进pom.xml。注意这里不要盲目引入ofdrw-full这种全量包,最好按需引入,能少引一个模块就少一点依赖冲突的风险。
<!-- ofdrw转换模块:PDF转OFD、OFD转PDF --> <dependency> <groupId>org.ofdrw</groupId> <artifactId>ofdrw-converter</artifactId> <version>1.24.0</version> </dependency> <!-- ofdrw签名模块:SM2签署OFD --> <dependency> <groupId>org.ofdrw</groupId> <artifactId>ofdrw-sign</artifactId> <version>1.24.0</version> </dependency>版本号我这里写的是集成时用的版本,你实际引入时一定要以Maven中央仓库里的最新稳定版为准。ofdrw-converter内部会依赖PDF解析相关的库,ofdrw-sign内部会带国密算法相关的实现,如果你的项目里已经用了PDFBox、iText或者BouncyCastle,一定要比对一下这些传递依赖的版本,不然很容易出现NoClassDefFoundError。
2.2 中文字体目录与JDK环境准备
ofdrw不像普通Java库那样只做逻辑运算,它做转换时要真真切切调用字体系统来渲染文字。这在Windows开发机上往往没问题,因为系统自带宋体、微软雅黑这些字体,但Linux服务器上就很容易翻车——默认环境往往只有有限的英文字体,中文渲染出来全是方块。
所以我第一步做的事,就是准备一个字体目录,比如/data/fonts,把需要的中文字体文件放进去。常用的有NotoSansCJK、阿里巴巴普惠体、思源黑体,这些字体可以免费商用。然后在应用配置里把这个目录配成一个参数,比如ofdrw.font-dir=/data/fonts,这样后续无论是转换还是签名,涉及到字体渲染的地方都能找到字体。
JDK版本方面,ofdrw的不同版本要求不一样,老版本JDK8可用,新版本可能要求JDK17。我建议项目如果比较新,直接用JDK17,省得后面想升级ofdrw版本时被基础环境卡住。
2.3 把转换和签名封装成Spring服务
依赖配好、字体目录准备好之后,我习惯先把工具方法封装成Spring管理的Service,后面所有业务代码只管调用,不直接和ofdrw打太多交道。
@Slf4j @Component public class OfdConvertService { /** * PDF转OFD */ public void pdfToOfd(String pdfPath, String ofdPath) throws Exception { try (OFDWriter writer = new OFDWriter(new Path(ofdPath))) { ConvertParser parser = new ConvertParser(); parser.convertPdf(new Path(pdfPath), writer); log.info("PDF转OFD成功,pdf={},ofd={}", pdfPath, ofdPath); } catch (Exception e) { log.error("PDF转OFD失败,pdf={}", pdfPath, e); throw e; } } /** * OFD转PDF,fontDir为中文字体目录 */ public void ofdToPdf(String ofdPath, String pdfPath, String fontDir) throws Exception { try { OFDPageConvert convert = new OFDPageConvert(new Path(ofdPath), fontDir); convert.pageConvertToPdf(new Path(pdfPath)); log.info("OFD转PDF成功,ofd={},pdf={}", ofdPath, pdfPath); } catch (Exception e) { log.error("OFD转PDF失败,ofd={}", ofdPath, e); throw e; } } }这里用try-with-resources去管理OFDWriter的关闭很重要,OFDWriter写文件时要维护整个OFD包的结构,不关闭的话输出文件很可能是不完整的。日志方面,我在成功和失败路径上都打印了文件路径,这样线上排查时能快速定位是哪一步出了问题。
3. PDF转OFD:实现环节拆解与代码示例
3.1 不要被"转换"两个字骗了,它其实是重建
PDF转OFD这个功能,如果你理解成格式转换,很可能会把结果想得太简单。实际上,ofdrw的ConvertParser做的是解析PDF的页面内容流,把文字、图片、图形、注释这些元素按位置提取出来,然后再把这些元素放进OFD的页面对象里重新组织。这个过程中,坐标系的换算、字体样式的映射、图片的重新编码,任何一个环节出错都可能让最终文件看起来不对劲。
这也是为什么会有"扫描版PDF转出来的OFD是白纸"的情况。扫描版PDF本质上每一页都是整张图片,没有文字层,自然提不出文字。如果业务场景里确实需要转这种扫描件,通常要先做OCR识别成带文字层的PDF,或者干脆放弃文字转换,直接把整页图片放进去。
3.2 核心代码与参数说明
上一节封装的Service里已经有了核心转换逻辑,这里我展开讲一下每个关键类的作用。
OFDWriter负责创建并维护OFD包结构,它接收一个Path参数,这个Path指向将要生成的OFD文件。ConvertParser是转换核心,调用它的convertPdf方法时,它会读取PDF文件,逐页解析内容,并在同一个OFDWriter实例中创建对应的OFD页面。整个过程只需要两个类就能完成基础转换。
实际业务中,我一般不会直接在Controller里调用这个Service,而是先考虑文件来源。如果是MultipartFile上传的,先保存到本地临时目录再转换;如果是服务器本地已有的路径,直接传路径就行。转换完成后,建议立刻检查生成文件的大小和页数,避免文件路径写错导致生成空文件。
3.3 转换效果验证的一个小方法
PDF转OFD之后,怎么快速确认转换结果是正常的?我的习惯是用ofdrw-reader模块把生成的OFD重新解析一遍,获取总页数,再和源PDF页数对比。如果页数都对不上,说明转换过程有问题,也不用继续往下走了。
// 用ofdrw-reader读取OFD文档信息 try (OFDReader reader = new OFDReader(new Path(ofdPath))) { OFDDir dir = reader.getOfdDir(); System.out.println("OFD页数: " + dir.getPages().size()); }这种验证成本很低,但能拦截一大批低级错误。尤其在做批量转换时,每个文件转完都做一次页数校验,比最后统一发现全是空文件再返工要高效得多。
3.4 我遇到过的三个PDF转OFD的坑
第一个坑是字体缺失导致的中文乱码。这个在第2节提过,Linux服务器上没有中文字体的话,PDF里的中文在转出来的OFD里可能变成乱码或方块。解决方式就是配好字体目录,别偷懒。
第二个坑是布局错位。PDF的坐标系原点和OFD的坐标系原点定义不一样,如果ofdrw版本偏老,某些带有复杂坐标变换的PDF转出来会出现元素整体偏移。我遇到一次就是把PDF账号信息页面转成OFD后,文字整体向下偏了一两个像素。这个问题的处理思路比较简单粗暴:升级ofdrw到新版本,然后重新测试。因为这类问题通常是底层坐标处理bug,靠业务侧去适配不太现实。
第三个坑是页面里的矢量图形丢失。某些PDF里的表格线、背景色块是以矢量形式绘制的,转换时如果解析器不支持某种路径绘制操作,图形就会静默丢弃。这个比较难从转换结果上直接看出来,所以我在批量转换后都会抽样打开几个OFD文件,肉眼检查一遍,再有针对性地补充处理。
4. OFD转PDF:反向转换的实现与定制
4.1 OFD转PDF使用的三个现实问题
从OFD转回PDF,这个需求我在项目里遇到的频率也不低。原因很现实:归档端要求OFD,但很多人的桌面环境里并没有能打开OFD的阅读器。转成PDF之后,浏览器能看、手机能看、打印机也能直接出纸。
不过OFD转PDF也有它的麻烦。最突出的是字体资源的匹配问题。OFD文件内部可能记录了它使用的字体名称,但没有把字体文件全部嵌进去,转换时就需要本机提供同名或兼容的字体。如果本机字体库不齐,转换出来的PDF一样会乱码。其次是布局精度,OFD和PDF的页面尺寸换算如果处理不当,转出来的PDF页面比例可能不对。最后是有一些OFD里使用了特殊绘制指令,转换器如果支持不全,某些元素会被跳过。
4.2 OFD转PDF核心代码
我在Service里封装好的ofdToPdf方法,核心逻辑就是创建OFDPageConvert实例,传入OFD文件路径和字体目录,然后调用pageConvertToPdf。
public void ofdToPdf(String ofdPath, String pdfPath, String fontDir) throws Exception { // fontDir不可为空,空目录会导致字体解析失败 OFDPageConvert convert = new OFDPageConvert(new Path(ofdPath), fontDir); convert.pageConvertToPdf(new Path(pdfPath)); }这里有个细节值得说:fontDir不仅是你想放中文字体就放中文字体,建议把字体目录里放全常见的中英文字体,因为OFD文件里可能引用了不同字体,转换时每遇到一种字体都会去目录里找。找不到就降级,而降级的结果就是字体变形。
4.3 输出质量和性能调优的细节
如果你发现OFD转PDF出来的文件,放大看之后字迹边缘发虚,大概率是渲染分辨率的问题。OFDPageConvert底层渲染时可以指定分辨率或缩放参数,适当调高DPI能让文字边缘更锐利,但代价是转换变慢、文件变大。我个人的参数选择是常规预览用默认即可,正式归档可以调高一档,没必要一味追求最高分辨率。
性能方面,OFD转PDF比PDF转OFD通常要慢一些,因为它要完成字体渲染、图形绘制这些更重的操作。遇到超大OFD文件时,如果使用默认内存配置,很容易OOM。我建议在批量转换场景里控制并发数,不要一次性把几百个文件丢给线程池硬扛。
5. SM2签名OFD:从证书到线上签名的完整流程
5.1 先理解OFD的SM2签名机制
OFD签名和普通文件的哈希签名不太一样,它是基于OFD包结构的规范签名。简单理解:OFD文件本质是一个ZIP包,里面装的是文档入口文件、页面描述、资源文件这些。签名时,ofdrw-sign会先圈定需要保护的文件范围,对这些文件的内容计算SM3摘要,然后调用SM2算法对摘要做签名,最后把签名值、签名者证书和签名属性信息一起写进OFD包的签名区。
SM2签名用的是签名者的私钥,验证时用配套的证书公钥。签名后任何人改了OFD包里的哪怕一个字节,重新计算的摘要都会和签名时记录的不一致,验签就会失败。这种设计保证了OFD文件的内容完整性和签名者身份的可验证性。
为什么用SM2而不是RSA?核心原因在于特定业务场景的要求。很多电子凭证、电子回单、电子档案系统的技术规范里明确要求使用国密算法,这种情况下你就得按规则来。ofdrw-sign对SM2的支持是原生集成,不需要自己再对接加密机或密码库,这也是我选它的重要原因。
5.2 前置素材:SM2证书、印章图片、签名配置参数
要签名,你得先有三样东西。
第一样是SM2证书和私钥,通常以PFX/P12格式文件保存。开发环境里可以用工具生成自签证书,但生产环境一定要用正规CA签发,并且确保证书里的密钥算法是SM2。我见过有人拿着RSA证书去签,结果签名组件直接报算法不匹配。别在这种地方浪费时间。
第二样是印章图片。电子签章在OFD页面上展示的形象就是印章图片,建议用PNG格式,背景透明,大小适中。图片分辨率太低了放大糊,太高了文件体积大,我一般选300dpi左右。
第三样是签名区域配置。你要决定印章盖在哪一页、页面上的坐标位置、印章宽高。OFD里的坐标单位一般是毫米,这个计算要提前量好,或者做成前台页面让业务人员拖拽确定。
5.3 签名实现代码
我这里给出一段完整的签名示例,基于ofdrw-sign的常见用法编写。注意代码里的类名和API在你使用的版本里可能略有差异,建议以你当前版本的实际源码为准。
@Slf4j @Component public class OfdSignService { /** * 对OFD文件执行SM2签名 * * @param srcOfd 原始OFD路径 * @param outOfd 签名后输出的OFD路径 * @param pfxPath SM2证书库路径 * @param password 证书密码 * @param stampImage 印章图片 * @param pageNo 印章所在页码,从1开始 * @param x 印章左上角x坐标,单位mm * @param y 印章左上角y坐标,单位mm */ public void signOfd(String srcOfd, String outOfd, String pfxPath, String password, BufferedImage stampImage, int pageNo, double x, double y) throws Exception { // 先复制一份,签名不污染原始OFD Files.copy(new Path(srcOfd), new Path(outOfd), StandardCopyOption.REPLACE_EXISTING); // 创建签名器 Signature signature = new Signature(new Path(outOfd)); // 设置签名算法为SM2 signature.setSignAlg(new SM2SignAlg()); // 加载SM2证书库 PKCS12KeyPair keyPair = new PKCS12KeyPair(new Path(pfxPath), password); // 构造签名配置 SignatureConfig config = new SignatureConfig(); config.setUserName("测试签章"); config.setSignatureName("电子签章"); config.setStampImage(stampImage); config.setSignPage(new SignPage(pageNo, x, y, 80, 40)); signature.setSignKeyPair(keyPair); signature.addSignatureConfig(config); // 执行签名 signature.exeSign(); log.info("OFD签名完成,out={}", outOfd); } }签名的基本流程是:复制文件、创建签名器、指定SM2算法和证书、配置签名位置、执行签名。执行完签名后,签名器会在OFD包内新增签名目录和签名值文件,并修改文档入口配置,把这些签名信息关联起来。
5.4 签名后的验证步骤
签名不是签完就算完事,我每次都会在开发环境先自动验签一遍。验签可以通过ofdrw-validator模块完成,也可以自己调用验签接口。核心验证点有两个:一是签名值本身是否能用证书公钥验通,二是文档内容是否完整未被篡改。
// 简易验签流程,核心API以实际版本为准 Signature signature = new Signature(new Path(outOfd)); ValidateResult result = signature.exeValidate(); if (!result.isValid()) { log.warn("OFD签名验证未通过,out={}", outOfd); }如果验签不通过,先别急着怀疑组件,从这三个方向排查:源文件是否被手动改过;证书和私钥是否匹配;签名时使用的算法和验签时是否一致。
5.5 SM2签名环节最容易踩的三个坑
第一个坑是证书密码被硬编码。代码能跑是能跑,但等证书更新或者密码变更的时候,到处找密码是谁配的就尴尬了。我习惯把密码放到Nacos或环境变量里,不落在代码仓库。
第二个坑是重复签名。有些业务会把同一份OFD反复提交签名,每次都在原文件上生成新签名,导致OFD包里出现多个签名记录。规范做法是先检查文件是否已经签过名,如果是,要么拒绝重复签名,要么基于最新副本重新签。
第三个坑是印章图片盖的位置很别扭。如果印章的坐标和宽高设置不合理,可能出现印章跑到页面边界外、盖住正文关键区域的情况。所以我在Service里对坐标和尺寸加了校验,超出页面范围的直接抛异常。
6. 常见问题排查与运维避坑
6.1 高频问题速查表
把我在整个集成过程中遇到的高频问题整理成一个速查表,方便你直接对照排查。
| 问题现象 | 常见原因 | 处理方式 |
|---|---|---|
| PDF转OFD后中文乱码或方块 | Linux服务器缺中文字体 | 配置字体目录,部署中文字体 |
| PDF转OFD后页面空白 | 扫描版PDF没有文字层 | 先OCR再转,或按图片页直接生成OFD |
| OFD转PDF时字体不对 | 字体目录缺少OFD引用的字体 | 补齐字体目录,用绝对路径 |
| 转出来的PDF文字发虚 | 渲染分辨率偏低 | 调高转换DPI参数 |
| 签名时报证书算法不匹配 | 证书是RSA或ECDSA | 确认使用SM2算法证书 |
| 签完名的OFD打不开 | 签名过程破坏了OFD包结构 | 对副本签名并重新验签 |
| 批量转换时内存溢出 | 并发过高、单文件过大 | 控制线程数、提高JVM内存、分批处理 |
6.2 版本依赖与SpringBoot生态的冲突处理
ofdrw不是孤立运行的,它内部依赖了一些PDF处理和加密算法相关的库。如果你的SpringBoot项目本身已经引入了iText、PDFBox、BouncyCastle,容易撞版本。我踩过一次jackson和commons相关的依赖冲突,排查了半天才定位到是传递依赖版本不一致。
我的处理方法是把转换和签名功能拆分成一个独立的Maven模块,这个模块里只保留ofdrw相关依赖,业务项目通过接口方式调用它。隔离之后,冲突范围被限制在模块内部,改动和升级都更可控。
6.3 线上部署的几条实测经验
第一,字体目录最好统一挂载,容器化部署时直接把字体目录打进镜像。这样不管部署到哪套环境,字体行为都是可预期的。
第二,日志里一定要带文件名和执行时间。我在Service里打印的每一条日志都包含输入文件、输出文件和耗时,这样线上出问题后,翻日志能立刻定位到具体是哪个文件、哪一步、花了多久。
第三,转换类操作尽量异步化。如果Web请求同步去做一个几十MB文件的转换,用户大概率会以为页面卡死了。我一般将转换任务提交到线程池,任务完成后把结果写入数据库,前端轮询告知状态。这样用户体验和接口成本都友好很多。
第四,SM2证书到期监控别忘。证书一旦过期,所有签名操作都会失败,而且是那种比较难排查的失败。我在系统里加了一个定时任务,每个月扫一次证书有效期,提前30天告警,上线以来确实避免过一次事故。
6.4 最后分享一个小技巧
如果你想在签名前就确认OFD文件结构是完好的,可以先借助ofdrw-reader把OFD解包看一遍,确认页面和资源文件都在,再进入签名流程。这样即使签名失败,你也能确认是签名环节的问题,而不是源文件本身已经损坏。这个习惯帮我省了很多排查时间。
还有一个容易被忽略的点:OFD转换和签名输出的目标文件,不要覆盖业务系统的原始文件。我在代码里一律是复制副本再处理,哪怕中途失败,原文件还是完好的,至少不会因为一次失败操作丢数据。
如果让我重新做一次这套集成,我会把验证环节往前挪。每完成一个转换功能或者签名功能,立刻写一段自动检查逻辑来验签、解析结构、对比页数,而不是等联调时让前端发现异常。这个习惯帮我省了大量排查时间。SM2证书有效期也是容易漏的点,证书到期前一个月就该用定时任务扫描告警,不然线上突然签不了章,业务那边催起来是真的头疼。ofdrw这套方案在我项目里已经稳定跑了一段时间,PDF和OFD双向转换、SM2签署OFD都达到了预期,希望这篇实战记录也能帮你把这条路走通。