最近在折腾一个电子档案相关的SpringBoot项目,业务上要求既能把历史PDF转成OFD归档,又要能接收对方发来的OFD文件转回PDF做在线预览,最后还要在归档前用SM2国密算法做电子签名。一整套流程走下来,踩了不少坑,也把整个方案跑通了。这篇就把完整的集成过程和实现代码整理出来,算是一个实战记录。
1. 为什么要集成OFD:需求背景与方案拆解
先说清楚OFD是个什么东西。OFD(Open Fixed-layout Document)是我国自主制定的版式文档格式标准,在电子发票、电子档案、公文交换这些场景里见得越来越多。它的定位跟PDF类似,都是“格式固定、不可随意编辑”的版式文档,但底层结构和PDF完全是两套体系。
在我们项目里,需求其实分成了三段:
- 历史存量文件都是PDF,验收方要求归档格式必须是OFD,所以要做一次批量转换;
- 业务过程中会收到上游单位发来的OFD文件,内部系统只支持PDF预览,所以需要把OFD转回PDF;
- 存档文件要具备法律效力,需要用SM2算法做数字签名,然后把签名后的OFD落库。
一开始我们也在评估是自研解析OFD还是用开源库。OFD虽然相关标准是公开的,但自己从头解析版式文档涉及的底层工作太多,比如XML解析、字体嵌入、页面渲染、签名结构,一个人做一个月都未必能把PDF转换这条路走通。后来调研了一圈,发现开源生态里ofdrw是相对最完整的选择,它把核心解析、格式转换、电子签名、渲染预览全包了,而且原生支持国密算法,这才定下来用它。
整体技术方案就是SpringBoot作为服务端,通过ofdrw提供的各个模块,分别实现PDF转OFD、OFD转PDF、SM2数字签名这三条链路。下面逐个模块展开讲。
2. 环境准备:依赖引入与开发前准备
2.1 引入ofdrw全量依赖
pom.xml里直接引入聚合包:
<dependency> <groupId>org.ofdrw</groupId> <artifactId>ofdrw-full</artifactId> <version>1.9.5</version> </dependency>ofdrw-full这个包会把核心模块都聚合进来,具体包括:
- ofdrw-core:OFD文档对象模型,负责文档的构建和解析;
- ofdrw-reader:读取OFD文件,提供页面访问能力;
- ofdrw-converter:格式转换核心,支持OFD转PDF、部分场景下的PDF转OFD;
- ofdrw-sign:电子签名模块,支持SM2签名、证书管理、验签;
- ofdrw-render:渲染模块,可以把OFD页面渲染为图片。
实际用的时候按需引入对应模块也可以,但直接上full包最省事,版本各模块之间也统一,不用抠兼容性问题。
2.2 确认JDK与字体环境
ofdrw依赖JDK 8以上的环境,项目里用的是JDK 1.8,跑下来没发现兼容性冲突。需要注意的是字体库,OFD转换PDF时中文字体的映射是个重头戏。转换引擎如果找不到对应字体,输出的PDF就会出现中文乱码或者“豆腐块”。服务器上最好提前准备常用中文字体,比如宋体、黑体、仿宋,放到指定目录,代码里显式把字体目录指给转换器。
2.3 证书与密钥材料的准备
做SM2签名,必须要有证书和私钥。ofdrw在签名时需要两个东西:
- 签名证书,X509格式,包含签名者身份信息和公钥;
- 对应的SM2私钥,用于生成签名值。
证书可以自己用代码动态生成,也可以用工具生成后再加载。后文在第5节会给出完整的生成代码。这里先提醒一句:测试阶段可以自己造证书,正式环境一定要用合规CA签发的国密证书,不然验签环节过不了。
3. PDF转OFD:转换流程与实现细节
3.1 转换原理简述
PDF和OFD虽然都是固定版式文档,但内部描述方式完全不同。PDF用底层的图形对象和内容流描述页面元素,OFD则以XML组织页面结构。所以从一个格式转到另一个格式,本质上是做一次“渲染层解释”,把PDF里的页面元素逐个还原出来,再按照OFD的结构重新组织。
这就带来一个天然限制:转换不是“无损”的。PDF里的复杂元素,比如透明度混合、特殊渐变、嵌入的交互对象,转成OFD时可能会出现位置偏移、颜色偏差、甚至元素丢失。在项目里这一点要提前做好评估,跟业务方说清楚。
3.2 代码实现:PDFToOFDConvert
ofdrw-converter里提供了一个转换入口,直接调用即可:
import org.ofdrw.core.OFD; import org.ofdrw.converter.PDFToOFDConvert; import java.nio.file.Path; import java.nio.file.Paths; public class PdfToOfdService { /** * PDF转OFD * * @param pdfPath 源PDF文件路径 * @param ofdPath 目标OFD文件路径 */ public void pdfToOfd(String pdfPath, String ofdPath) throws Exception { Path src = Paths.get(pdfPath); Path dst = Paths.get(ofdPath); // 创建OFD文档对象 OFD ofd = new OFD(); // 执行转换 PDFToOFDConvert convert = new PDFToOFDConvert(src); convert.convert(ofd); // 保存结果 ofd.save(dst); } }讲几个代码里看不出来的要点:
OFD对象创建出来是个空文档,convert.convert()把PDF页面逐个解析并挂到文档对象上,最后save落盘;- 源PDF如果是扫描件(本身是图片),转换出来就是带大图片的OFD,体积通常会变大,这是正常现象;
PDFToOFDConvert内部读取PDF时依赖字体映射,如果转换出来的OFD打开缺字,先去排查服务器字库。
3.3 转换后的校验
转换完成后,建议做个基本校验:把生成的OFD再读出来,确认页数和内容完整。
import org.ofdrw.reader.OFDReader; public void validateOfd(String ofdPath) throws Exception { try (OFDReader reader = new OFDReader(Paths.get(ofdPath))) { // 获取文档总页数 int totalPage = reader.getNumberOfPages(); System.out.println("OFD文档共 " + totalPage + " 页"); } }如果页面数与源PDF一致,基础转换基本没问题。如果页面数对不上,多半是源PDF本身存在异常页面,比如空页、损坏页。
3.4 批量转换的性能建议
项目中如果是一次性处理几十万份存量PDF,建议分批跑而不是一把梭。我在实际处理时发现,转换是CPU密集型操作,单进程跑满一个核大概每秒能处理2~3页中等复杂度的PDF。批量场景下有两个优化方向:
- 设置合理的线程数量,用线程池控制在CPU核心数以内,避免上下文切换开销过大;
- 转换过程中不要同时操作数据库,先转换落临时目录,全部完成后统一登记归档,减少IO竞争。
另外,转换过程中如果单个PDF文件非常大,比如几百兆的设计稿,JVM堆内存要给足,同时建议用流式读取而不是一次性把PDF全部载入内存。
4. OFD转PDF:在线预览与反向转换
4.1 转换原理
OFD转PDF原理跟反方向类似,也是把OFD的页面元素重新渲染为PDF的内容流。但是这条路比PDF转OFD要顺一些,因为OFD本身就是结构化的XML描述,解析时能拿到完整的页面元素信息,还原度相对更高。
4.2 代码实现:OFDConverter
import org.ofdrw.converter.OFDConverter; import org.ofdrw.font.FontHelper; import org.ofdrw.reader.OFDReader; import java.nio.file.Path; import java.nio.file.Paths; public class OfdToPdfService { /** * OFD转PDF * * @param ofdPath 源OFD文件路径 * @param pdfPath 目标PDF文件路径 */ public void ofdToPdf(String ofdPath, String pdfPath) throws Exception { // 设置字体目录,解决中文乱码问题 FontHelper.setFontDir(Paths.get("/usr/share/fonts/chinese")); try (OFDReader reader = new OFDReader(Paths.get(ofdPath)); OFDConverter converter = new OFDConverter(reader)) { converter.convert(Paths.get(pdfPath)); } } }这个代码里最关键的一行是FontHelper.setFontDir。OFD文档里记录字体时一般只带字体名,比如“宋体”“黑体”,转换引擎需要在本机字体库中找到对应字体文件,才能正确渲染字形。如果本机没有这些字体,输出PDF里中文就会全部变成占位符或乱码。所以第一步永远是先把服务器字体配齐。
4.3 页面清晰度调整
OFD转PDF时,如果发现输出的PDF在放大后文字发虚、边缘锯齿明显,多半是渲染DPI偏低。可以在转换前调整渲染参数:
import org.ofdrw.converter.Picture; // 设置渲染DPI,数值越大清晰度越高,输出文件也会更大 Picture.setDpi(120);默认DPI一般是96,屏幕上看着没问题,打印或高倍放大时会露馅。我们项目里对清晰度要求高,统一调到144,文件体积增加大概30%,但是视觉效果有明显提升。
4.4 在线预览实现方案
浏览器原生不支持OFD预览,所以我的做法是:后端把OFD转成PDF,前端用PDF预览组件展示。
流程是这样的:
用户上传OFD -> 后端转PDF -> 返回PDF访问地址 -> 前端加载预览如果想把流程做轻一点,可以直接把OFD渲染成图片返回给前端,用ofdrw-render模块:
import org.ofdrw.render.OFDRenderer; public void renderOfdPage(String ofdPath, int pageIndex, String imagePath) throws Exception { OFDRenderer renderer = new OFDRenderer(Paths.get(ofdPath)); // 渲染指定页面,返回渲染结果图片对象 renderer.render(pageIndex, 120); renderer.close(); }渲染成图片的好处是前端零依赖,缺点是不能选词、不能缩放体验一般。实际项目中我倾向于优先输出PDF给前端预览,只有在PDF预览组件不支持的极端环境下才退回到图片方案。
5. SM2数字签名:证书生成与OFD签名实现
5.1 数字签名解决什么问题
业务上接收OFD归档文件时,签名最直接的作用是保证两个点:
- 文件在传输过程中没有被篡改;
- 文件确实来自声称的单位或个人。
SM2是我国自主的椭圆曲线公钥密码算法,对应标准GM/T 0003。相比RSA,SM2在同等安全强度下密钥更短、计算更快。电子政务和合规归档场景中,SM2签名是硬性要求,不能用RSA应付。
5.2 动态生成SM2密钥对与自签名证书
签名之前得有证书。这里给出基于BouncyCastle的SM2密钥对和自签名证书生成完整代码:
import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.asn1.x9.X9ECParameters; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.spec.ECParameterSpec; import org.bouncycastle.math.ec.ECCurve; import java.math.BigInteger; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.security.SecureRandom; import java.security.Security; import java.security.cert.X509Certificate; import java.util.Date; public class Sm2CertUtil { static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } public static KeyPair generateSm2KeyPair() throws Exception { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", "BC"); // 使用SM2推荐曲线参数 ECParameterSpec sm2Spec = new ECParameterSpec( (ECCurve) new ECCurve.Fp( new BigInteger("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF", 16), new BigInteger("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC", 16), new BigInteger("28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93", 16), BigInteger.valueOf(1L), BigInteger.TWO ), ((X9ECParameters) new X9ECParameters( new BigInteger("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF", 16), new BigInteger("FFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC", 16), new BigInteger("28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93", 16), new BigInteger("32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7", 16), BigInteger.ONE, null )) ); keyPairGenerator.initialize(sm2Spec, new SecureRandom()); return keyPairGenerator.generateKeyPair(); } /** * 生成自签名证书 */ public static X509CertificateHolder generateCert(KeyPair keyPair) throws Exception { X500Name subject = new X500Name("CN=TestUser, OU=TestDept, O=TestOrg, C=CN"); BigInteger serial = new BigInteger(64, new SecureRandom()); Date notBefore = new Date(); Date notAfter = new Date(notBefore.getTime() + 365L * 24 * 3600 * 1000); X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( subject, serial, notBefore, notAfter, subject, keyPair.getPublic()); // 基本约束:非CA证书 certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); // 密钥用途:数字签名 certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)); return certBuilder.build(new org.bouncycastle.operator.jcajce.JcaContentSignerBuilder("SM3withSM2") .setProvider("BC") .build(keyPair.getPrivate())); } }实际项目中使用这组代码生成证书后,证书对象可以序列化后存库或者导出为文件,私钥也需要妥善保存。
5.3 导出证书与私钥
生成完证书和私钥后,可以把它们导出为文件,方便后续加载使用:
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import java.io.FileWriter; import java.security.cert.X509Certificate; public void exportCert(KeyPair keyPair, X509CertificateHolder certHolder) throws Exception { X509Certificate cert = new JcaX509CertificateConverter() .setProvider("BC") .getCertificate(certHolder); try (JcaPEMWriter writer = new JcaPEMWriter(new FileWriter("cert.pem"))) { writer.writeObject(cert); } try (JcaPEMWriter writer = new JcaPEMWriter(new FileWriter("private.pem"))) { writer.writeObject(keyPair.getPrivate()); } }以后签名时,直接从这两个文件加载即可。正式环境建议把私钥放到硬件加密机或KMS里,这里导出文件的方式只适用于开发和测试环境。
5.4 OFD签名核心代码
签名流程使用ofdrw-sign模块的OFDSigner:
import org.ofdrw.gm.cert.Signer; import org.ofdrw.reader.OFDReader; import org.ofdrw.sign.OFDSigner; import org.ofdrw.sign.SignMode; import org.ofdrw.sign.SignatureConfig; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.openssl.PEMKeyPair; import org.bouncycastle.openssl.PEMParser; import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; import java.io.FileReader; import java.nio.file.Path; import java.nio.file.Paths; import java.security.PrivateKey; import java.util.Date; public class OfdSignService { /** * 使用SM2对OFD文件签名 * * @param srcOfd 待签名OFD文件 * @param dstOfd 签名后输出文件 * @param certPath 证书文件路径(PEM格式) * @param keyPath 私钥文件路径(PEM格式) */ public void signOfd(String srcOfd, String dstOfd, String certPath, String keyPath) throws Exception { Path src = Paths.get(srcOfd); Path dst = Paths.get(dstOfd); // 加载证书 X509CertificateHolder certHolder; try (PEMParser parser = new PEMParser(new FileReader(certPath))) { certHolder = (X509CertificateHolder) parser.readObject(); } // 加载私钥 PrivateKey privateKey; try (PEMParser parser = new PEMParser(new FileReader(keyPath))) { PEMKeyPair keyPair = (PEMKeyPair) parser.readObject(); privateKey = new JcaPEMKeyConverter() .setProvider("BC") .getKeyPair(keyPair) .getPrivate(); } // 配置签名参数 SignatureConfig config = new SignatureConfig(); config.setSignMode(SignMode.Enveloped); config.setSigner("某单位"); config.setSignatureID("s1"); config.setClaimedSignTime(new Date()); config.setLocation("归档中心"); config.setReason("文件归档确认"); // 如果不需要盖章,可以不设置印章图片 // config.setSeal(Paths.get("seal.png")); OFDSigner signer = new OFDSigner(src, dst); try { signer.setConfig(config); // 执行签名 signer.sign(new Signer(certHolder, privateKey)); } finally { signer.close(); } } }这段代码是整个签名链路最核心的部分。几个参数简单解释一下:
setSignMode(SignMode.Enveloped):表示签名信息嵌入OFD文件内部,形成完整的签名包络。还有一种模式是Enveloping,签名数据单独放置,实际场景中Enveloped更常用;setSignatureID("s1"):给签名节点一个唯一标识,验签时靠这个ID定位签名;setClaimedSignTime:签名声称时间,会写入签名属性里。注意签名时间不等同于服务器当前时间戳,如果业务上要求强时间戳,还需要接入可信时间戳服务,在签名配置中设置时间戳证书。
5.5 签名验证
签名做完之后,先用验签工具自测一遍,确认签名有效:
import org.ofdrw.gm.cert.Validator; import org.ofdrw.sign.OFDValidator; import org.ofdrw.sign.Signature; import java.nio.file.Paths; import java.util.List; public void verifyOfd(String ofdPath) throws Exception { OFDValidator validator = new OFDValidator(Paths.get(ofdPath), Paths.get(ofdPath.replace(".ofd", "_verify.ofd"))); // 获取文档内所有签名列表 List<Signature> signatures = validator.getSignatureList(); System.out.println("签名数量: " + signatures.size()); for (Signature signature : signatures) { // 逐个验证签名有效性 boolean valid = validator.validate(signature.getSignatureId()); System.out.println("签名ID: " + signature.getSignatureId() + " 验证结果: " + valid); } }这里有个细节:OFDValidator的第二个参数是验签后的输出文件路径,因为验签过程中可能会生成新的签名验证信息,需要落盘。验证时需要注意证书链的信任关系,如果签发证书的CA不在信任库里,验签会失败。
5.6 签名结果说明
签名之后的OFD文件体积会增加一小部分,主要是签名块和证书信息占了空间。从使用者视角看,签名后的文件还是OFD格式,正常解析不受影响。如果用签章阅读器打开,能看到签名列表和签名者信息;如果被篡改过,验签工具会直接报警。
6. 实战中遇到的坑与排查记录
这一节全是项目推进过程中真实踩过的问题,能省下不少排查时间。
6.1 字体缺失导致中文乱码或方框
这是OFD转PDF过程中出现频率最高的问题。现象是转换后的PDF里中文全部变成方框或者乱码。排查思路:
- 确认服务器是否安装了中文字体,可以执行
fc-list :lang=zh查看; - 如果没装,用
yum install fontconfig加字体包,或者直接把Windows下的simsun.ttc、simhei.ttf等字体文件上传到服务器字体目录; - 代码里设置
FontHelper.setFontDir指向字体目录。
还有一个特殊情况:源OFD文档里指定的字体名和服务器字体库里的字体名不一致。比如文档里写的是“仿宋_GB2312”,服务器上只有“FangSong_GB2312”,这时需要做字体映射,把文档字体名映射到实际字体文件。
6.2 大文件转换导致内存溢出
转换一个体积比较大的PDF(比如上百MB)时,JVM直接报了OOM。原因是PDFToOFDConvert转换时会把整个PDF内容加载到内存中构建对象模型。解决方案是两个方向:
一是调大JVM堆内存,在启动参数里加-Xmx2g甚至更高;二是转换前对PDF做瘦身,比如先删除冗余页面、压缩图片,再执行转换。如果业务上允许分页处理,也可以按页拆分PDF,逐个转换后再合并OFD,这样内存占用能控制在一个稳定的水平。
6.3 签名后OFD打开报错
有一次签名完成后,用阅读器打开文件直接报“文档结构异常”。排查后发现是因为源OFD文件本身就有问题——文件是用旧版本工具生成的,页面结构不规范。ofdrw解析时勉强能读,但重新签名保存后就暴露出结构问题。
所以签名前一定要先做一次完整性检查:
public void checkOfd(String ofdPath) { try (OFDReader reader = new OFDReader(Paths.get(ofdPath))) { int pages = reader.getNumberOfPages(); if (pages <= 0) { throw new RuntimeException("OFD文件不包含有效页面"); } } catch (Exception e) { throw new RuntimeException("OFD文件解析失败", e); } }如果解析阶段就异常,这种文件就不要进签名流程,直接打回给上游单位重新生成。
6.4 转换后PDF页面偏移
OFD转PDF时偶尔会出现某个页面整体偏移,文字和图片位置不对。排查后定位到原因:有些OFD源文件里页面尺寸使用了PDF标准里不常见的长度单位,转换时DPI换算产生误差。
解决方法是统一设置渲染DPI,确保转换时的像素密度一致:
Picture.setDpi(120);如果设置了DPI仍然偏移,就检查源OFD的页面尺寸属性,手动修正到标准A4参数(2480×3508像素,对应210×297毫米,120DPI)。
6.5 签名时间与业务时间不一致
setClaimedSignTime设置的时间虽然写进了签名属性,但如果业务上需要借助可信时间戳确保“签名发生在某个时刻”,那就要引入时间戳服务。时间戳的作用是在签名值里绑定一个由第三方机构签发的可信时间,防止签名者把签名时间往前或往后改。对法律效力要求高的场景,这一步不能省。
6.6 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| OFD转PDF中文乱码 | 服务器缺少中文字体 | 安装字体并设置字体目录 |
| PDF转OFD后体积异常增大 | 源PDF中包含大量图片元素 | 转换前压缩图片;检查渲染DPI |
| 签名后文件无法打开 | 源OFD结构损坏 | 签名前先做解析检查 |
| 转换后页面偏移 | DPI设置不一致 | 统一设置Picture.setDpi |
| 验签失败提示证书不受信任 | 证书链不完整 | 导入CA根证书到信任库 |
| 大文件转换OOM | JVM堆内存不足 | 调大堆内存,或分页转换后合并 |
7. 项目落地后的体会
整套流程跑通之后,我最大的感受是:OFD生态虽然不如PDF成熟,但作为国产标准,它在政务、档案领域的支持力度是肉眼可见地在增长。集成ofdrw这种开源库,核心价值是把标准解析、格式转换、国密签名这些底层工作包装起来,让业务侧只需要关注文件流转逻辑。
在实际项目中,我建议把转换、签名、校验这三个能力封装成独立的Service组件,通过接口暴露给上层调用,这样不管将来是要切消息队列做异步批量处理,还是要加一层缓存优化频繁转换场景,改起来都很方便。尤其要注意的一点是,所有文件处理过程都要记录日志,包括源文件路径、目标文件路径、转换耗时、操作人、操作时间,万一归档文件出了问题,排查时候能快速定位。
另外,如果你所在的项目也是政务类系统,采购的国产化服务器和操作系统上跑SpringBoot,务必提前验证ofdrw依赖的字体库和BouncyCastle在这些环境下的兼容性。我们当时在交叉验证时发现不同国产系统对字体管理的路径约定不太一样,这个在部署前就要处理好,否则上线后才发现会很被动。
最后分享一个小技巧:开发阶段如果不想每次生成证书那么麻烦,可以把证书和私钥生成过程做成一个启动时自动执行的初始化Bean,自动创建测试证书文件到固定目录。等接入正式CA证书时,只改配置项切换证书来源即可,开发联调效率能提升不少。