做企业端开发的朋友应该都有这种体会业务系统里版本式文档这件事看着简单做起来全是规则。我最近接手的一个项目上游系统给的全是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-converterPDF转OFD、OFD转PDF/图片本文两个转换需求都用它ofdrw-publisher从零生成OFD文件手工排版生成OFDofdrw-signOFD签名与验签SM2签署OFD、签名校验ofdrw-validator按规范校验OFD签名后结构自检在SpringBoot里我采用的是Controller接请求、Service调工具层的结构。Controller负责接收文件路径或者MultipartFileService层封装转换和签名逻辑底层调用ofdrw的API。这样做的原因是业务系统后面可能还会接入自动归档、批量加签、定时验签这些功能如果为了快直接把ofdrw的调用散落在Controller里后期维护会很难受。2. 环境准备与SpringBoot集成前的关键配置2.1 Maven依赖怎么加确定用ofdrw之后第一步就是把它加进pom.xml。注意这里不要盲目引入ofdrw-full这种全量包最好按需引入能少引一个模块就少一点依赖冲突的风险。!-- ofdrw转换模块PDF转OFD、OFD转PDF -- dependency groupIdorg.ofdrw/groupId artifactIdofdrw-converter/artifactId version1.24.0/version /dependency !-- ofdrw签名模块SM2签署OFD -- dependency groupIdorg.ofdrw/groupId artifactIdofdrw-sign/artifactId version1.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转PDFfontDir为中文字体目录 */ 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再转或按图片页直接生成OFDOFD转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都达到了预期希望这篇实战记录也能帮你把这条路走通。