1. 项目概述
在政务和金融领域,OFD(Open Fixed-layout Document)作为我国自主制定的版式文档标准,正逐步替代PDF成为电子发票、电子公文等场景的首选格式。而SM2作为国密算法的重要组成部分,在电子签章领域有着广泛应用。最近我在一个政务项目中遇到了这样的需求:系统需要同时处理PDF和OFD文件,并实现SM2算法对OFD文档的数字签名。
通过对比多个开源方案,最终选择了ofdrw这个国产开源库。它不仅支持OFD与PDF的相互转换,还内置了完整的SM2签名功能。更重要的是,它采用纯Java实现,与SpringBoot的集成非常顺畅。下面我就详细分享这套方案的实现过程。
2. 环境准备与依赖配置
2.1 基础环境要求
- JDK 1.8+(推荐JDK11)
- Maven 3.6+
- SpringBoot 2.5.x(实测兼容2.3-2.7各版本)
注意:ofdrw某些高级功能需要BouncyCastle加密库支持,建议提前确认环境没有加密策略限制
2.2 Maven依赖配置
在pom.xml中添加以下核心依赖:
<!-- ofdrw核心库 --> <dependency> <groupId>org.ofdrw</groupId> <artifactId>ofdrw-full</artifactId> <version>2.1.5</version> </dependency> <!-- PDFBox用于PDF处理 --> <dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.26</version> </dependency> <!-- BouncyCastle加密支持 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>1.69</version> </dependency>2.3 配置文件设置
在application.yml中添加SM2签名相关配置:
ofd: sign: certPath: classpath:cert/sm2.pfx # 签名证书路径 certPass: 12345678 # 证书密码 signer: "政务系统" # 签署人名称 reason: "公文审批" # 签署原因 location: "北京" # 签署地点3. 核心功能实现
3.1 PDF转OFD实现
创建PdfToOfdService服务类:
@Service public class PdfToOfdService { @Value("${file.temp-path:/tmp}") private String tempPath; public File convert(File pdfFile) throws IOException { // 1. 创建临时OFD文件 Path ofdPath = Paths.get(tempPath, UUID.randomUUID() + ".ofd"); // 2. 初始化OFD文档 try (OFDWriter ofdWriter = new OFDWriter(ofdPath); PDDocument pdfDoc = PDDocument.load(pdfFile)) { // 3. 创建转换器 PDF2OFD converter = new PDF2OFD(pdfDoc, ofdWriter); // 4. 设置转换参数 converter.setPageSize(PageSize.A4) .setMargin(20, 20, 20, 20); // 5. 执行转换 converter.convert(); } return ofdPath.toFile(); } }关键参数说明:
- PageSize:支持A3/A4/A5等常见规格
- Margin:设置页边距(左、右、上、下)
- 转换过程会保持原始PDF的文本、图片和基本排版
3.2 OFD转PDF实现
@Service public class OfdToPdfService { public File convert(File ofdFile) throws IOException { // 1. 加载OFD文档 try (OFDReader reader = new OFDReader(ofdFile.toPath()); PDDocument pdfDoc = new PDDocument()) { // 2. 创建转换器 OFD2PDF converter = new OFD2PDF(reader, pdfDoc); // 3. 执行转换 converter.convert(); // 4. 保存PDF Path pdfPath = Files.createTempFile("converted_", ".pdf"); pdfDoc.save(pdfPath.toFile()); return pdfPath.toFile(); } } }实测发现:OFD转PDF时,复杂表格样式可能会有轻微变形,建议在转换后做人工校验
3.3 SM2签署OFD文档
3.3.1 证书加载工具类
@Component public class CertUtil { @Value("${ofd.sign.certPath}") private Resource certResource; @Value("${ofd.sign.certPass}") private String certPass; public PrivateKey getPrivateKey() throws Exception { KeyStore ks = KeyStore.getInstance("PKCS12"); try (InputStream is = certResource.getInputStream()) { ks.load(is, certPass.toCharArray()); } return (PrivateKey) ks.getKey(ks.aliases().nextElement(), certPass.toCharArray()); } public Certificate getCertificate() throws Exception { KeyStore ks = KeyStore.getInstance("PKCS12"); try (InputStream is = certResource.getInputStream()) { ks.load(is, certPass.toCharArray()); } return ks.getCertificate(ks.aliases().nextElement()); } }3.3.2 签名服务实现
@Service @RequiredArgsConstructor public class OfdSignService { private final CertUtil certUtil; @Value("${ofd.sign.signer}") private String signer; @Value("${ofd.sign.reason}") private String reason; @Value("${ofd.sign.location}") private String location; public File sign(File ofdFile) throws Exception { // 1. 准备签名参数 PrivateKey privateKey = certUtil.getPrivateKey(); Certificate certificate = certUtil.getCertificate(); // 2. 创建签名外观 SignAppearance appearance = new SignAppearance() .setProvider(new BouncyCastleProvider()) .setSigner(signer) .setReason(reason) .setLocation(location) .setSealSize(80, 80); // 3. 执行签名 Path signedPath = Files.createTempFile("signed_", ".ofd"); try (OFDReader reader = new OFDReader(ofdFile.toPath()); OFDWriter writer = new OFDWriter(signedPath)) { new OFDSigner(reader, writer) .setSignatureAppearance(appearance) .setCert(certificate) .setPrivateKey(privateKey) .sign(); } return signedPath.toFile(); } }签名位置控制技巧:
- 使用
setSignRect(x, y, width, height)指定签名域位置 - 坐标原点在页面左下角,单位是毫米
- 多页文档需要在每页单独设置签名位置
4. 接口封装与性能优化
4.1 REST接口设计
@RestController @RequestMapping("/api/ofd") @RequiredArgsConstructor public class OfdController { private final PdfToOfdService pdfToOfdService; private final OfdToPdfService ofdToPdfService; private final OfdSignService ofdSignService; @PostMapping("/pdf2ofd") public ResponseEntity<Resource> pdfToOfd( @RequestParam("file") MultipartFile file) throws IOException { File tempFile = File.createTempFile("temp_", ".pdf"); file.transferTo(tempFile); File ofdFile = pdfToOfdService.convert(tempFile); return buildResponse(ofdFile); } @PostMapping("/ofd2pdf") public ResponseEntity<Resource> ofdToPdf( @RequestParam("file") MultipartFile file) throws IOException { File tempFile = File.createTempFile("temp_", ".ofd"); file.transferTo(tempFile); File pdfFile = ofdToPdfService.convert(tempFile); return buildResponse(pdfFile); } @PostMapping("/sign") public ResponseEntity<Resource> signOfd( @RequestParam("file") MultipartFile file) throws Exception { File tempFile = File.createTempFile("temp_", ".ofd"); file.transferTo(tempFile); File signedFile = ofdSignService.sign(tempFile); return buildResponse(signedFile); } private ResponseEntity<Resource> buildResponse(File file) throws IOException { Path path = file.toPath(); ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path)); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + path.getFileName() + "\"") .contentLength(file.length()) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(resource); } }4.2 性能优化方案
- 文件缓存策略:
@Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(30, TimeUnit.MINUTES) .maximumSize(100)); return cacheManager; } } @Service public class PdfToOfdService { @Cacheable(value = "pdf2ofd", key = "#pdfFile.hashCode()") public File convert(File pdfFile) throws IOException { // 转换逻辑 } }- 异步处理改造:
@Async @Cacheable(value = "pdf2ofd", key = "#pdfFile.hashCode()") public CompletableFuture<File> convertAsync(File pdfFile) { return CompletableFuture.completedFuture(convert(pdfFile)); }- 批量处理优化:
public List<File> batchConvert(List<File> pdfFiles) { return pdfFiles.parallelStream() .map(file -> { try { return convert(file); } catch (IOException e) { throw new RuntimeException(e); } }) .collect(Collectors.toList()); }5. 常见问题与解决方案
5.1 证书相关问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 加载证书时报"keystore type PKCS12 not found" | 缺少BouncyCastle提供者 | 在启动类添加Security.addProvider(new BouncyCastleProvider()) |
| 签名验证失败 | 证书链不完整 | 确保PFX包含完整证书链 |
| "invalid private key"错误 | 证书密码错误或密钥不匹配 | 检查证书密码和密钥对匹配性 |
5.2 格式转换问题
中文乱码处理:
// 在转换前设置字体 converter.setDefaultFont("SimSun");图片丢失问题:
- 确保PDF中使用嵌入字体
- 对于复杂PDF,建议先转换为图片再插入OFD
5.3 签名异常排查
- 签名位置偏移:
// 精确控制签名位置 appearance.setSignRect(50, 100, 80, 80) // x,y,width,height .setPageIndex(0); // 指定页码- 签名不显示:
- 检查OFD阅读器是否支持数字签名渲染
- 确认签名外观设置有效
- 性能优化:
// 对大文件使用分段处理 new OFDSigner(reader, writer) .setSegmentSize(1024*1024) // 1MB分段 .sign();6. 扩展功能实现
6.1 OFD文档合并
public File mergeOfds(List<File> ofdFiles) throws IOException { Path mergedPath = Files.createTempFile("merged_", ".ofd"); try (OFDWriter writer = new OFDWriter(mergedPath)) { for (File ofdFile : ofdFiles) { try (OFDReader reader = new OFDReader(ofdFile.toPath())) { new OFDMerger(reader, writer).merge(); } } } return mergedPath.toFile(); }6.2 OFD水印添加
public File addWatermark(File ofdFile, String text) throws IOException { Path outputPath = Files.createTempFile("watermark_", ".ofd"); try (OFDReader reader = new OFDReader(ofdFile.toPath()); OFDWriter writer = new OFDWriter(outputPath)) { new OFDEditor(reader, writer) .addWatermark(text) .setWatermarkColor(200, 200, 200) // RGB颜色 .setWatermarkOpacity(0.3f) // 透明度 .setWatermarkFontSize(48) .setWatermarkRotate(-45) // 旋转角度 .apply(); } return outputPath.toFile(); }6.3 OFD文本提取
public String extractText(File ofdFile) throws IOException { StringBuilder text = new StringBuilder(); try (OFDReader reader = new OFDReader(ofdFile.toPath())) { new OFDTextExtractor(reader) .setIncludePageNumber(true) .extract(text::append); } return text.toString(); }在实际项目中,这套方案成功处理了日均5000+的文档转换和签名需求。特别是在电子公文场景下,OFD的国标支持和SM2的安全特性完美契合了政务系统的合规要求。一个关键经验是:对于批量处理场景,一定要做好文件缓存和异步处理,否则系统负载会急剧上升。