嘉峪关市网站建设_网站建设公司_一站式建站_seo优化
2025/12/26 16:50:51 网站建设 项目流程

基于Freemarker与JBIG压缩生成PDF电子凭证

在农村普惠金融业务中,POS终端每完成一笔交易,用户都需要一张清晰、合规且具备法律效力的电子凭证。这张看似简单的“小票”,背后却承载着对账依据、争议处理和监管审计等多重职责。然而,在实际落地过程中,我们常常面临几个棘手问题:原始手写签名图片动辄数百KB,大量存储成本高;中文字体渲染易乱码;系统需支持多类缴费模板并快速响应并发请求。

如何在资源受限的环境下,高效生成美观、安全、可追溯的PDF凭证?本文将分享一套已在生产环境稳定运行的技术方案——通过 Freemarker 模板引擎 + JBIG 图像压缩技术,实现轻量级、高性能的电子凭证服务。

这套系统广泛应用于助农取款、水电煤缴费、广电签约等场景,支撑日均数万笔交易。其核心思路是:前端采集数据时对签名图进行高压缩处理,后端使用模板动态填充内容,并将HTML精准转为PDF输出。整个流程兼顾了性能、兼容性与扩展性。


技术选型与整体架构

我们采用的是SpringMVC + Freemarker + Flying Saucer (基于 iText) + JBIGKit的组合,各组件分工明确:

  • Freemarker负责模板解析与数据绑定,支持灵活的条件判断和格式化逻辑;
  • Flying Saucer将结构化的 HTML 渲染成 PDF,保留 CSS 样式布局能力;
  • iText + itext-asian解决中文字符嵌入问题,确保字体跨平台一致显示;
  • JBIGKit实现二值图像(如签名)的无损高压缩,显著降低传输与存储开销。

典型调用链如下:

  1. POS设备采集用户信息及BMP格式手写签名;
  2. 使用 JBIG 算法将签名压缩为 Hex 字符串(压缩比可达 100:1);
  3. 数据以 JSON 形式上传至后端服务;
  4. 后端根据交易类型选择对应 HTML 模板,注入交易字段;
  5. 解压签名图像并嵌入 Base64 编码至 HTML;
  6. 利用 Flying Saucer 渲染为 PDF,返回预览或下载流。

这一设计不仅减少了网络传输压力,也避免了服务器端长期保存大体积临时文件的风险。


关键依赖配置

以下是pom.xml中的核心依赖项:

<dependencies> <!-- Freemarker 模板引擎 --> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.31</version> </dependency> <!-- HTML to PDF 渲染组件 --> <dependency> <groupId>org.xhtmlrenderer</groupId> <artifactId>flying-saucer-pdf-itext5</artifactId> <version>9.1.22</version> </dependency> <dependency> <groupId>org.xhtmlrenderer</groupId> <artifactId>flying-saucer-core</artifactId> <version>9.1.22</version> </dependency> <!-- iText 中文支持 & 字体嵌入 --> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itext-asian</artifactId> <version>5.2.0</version> </dependency> <!-- JBIG 图像压缩解压库 --> <dependency> <groupId>uk.ac.cam.cl.mgk25.jbigkit</groupId> <artifactId>jbigkit</artifactId> <version>2.1</version> </dependency> <!-- 工具类 --> <dependency> <groupId>commons-lang</groupId> <artifactId>commons-lang</artifactId> <version>2.6</version> </dependency> <dependency> <groupId>com.google.zxing</groupId> <artifactId>core</artifactId> <version>3.4.1</version> </dependency> </dependencies>

特别说明:flying-saucer-pdf-itext5对接的是 iText 5.x 版本,若项目已升级到 iText 7,请注意 API 不兼容问题。此外,itext-asian提供了 SimSun、SimHei 等常用中文字体支持,只需在代码中注册即可直接使用。


配置常量定义

为了统一管理路径、文件名和映射关系,我们封装了一个Constants类:

package com.elec.pdf.util; import java.util.HashMap; import java.util.Map; public class Constants { public interface PDF_CONFIG { String PDF_SIGN_CFG_PATH = "config/netsignagent.properties"; String PDF_FILE_PATH = "/data/pdf/temp/"; String PDF_FONT_PATH = "templates/fonts/simhei.ttf"; String PDF_HEADER_LOGO = "templates/images/header_logo.jpg"; String DOWNLOAD_URL_PREFIX = "https://fileserver.com/download/"; String PDF_SUFFIX = ".pdf"; String JBIG_SUFFIX = ".jbig"; String BMP_SUFFIX = ".bmp"; String JPG_SUFFIX = ".jpg"; String OP_PREVIEW = "01"; String OP_DOWNLOAD = "02"; String IMG_BASE64_HEADER = "data:image/jpeg;base64,"; } // 交易码 → 模板文件名 映射 public static final Map<String, String> TXN_TEMPLATE_MAP = new HashMap<>(); static { TXN_TEMPLATE_MAP.put("200401", "ZNWDMTemplate.html"); // 助农取款 TXN_TEMPLATE_MAP.put("200402", "ZNTRANTemplate.html"); // 助农转账 TXN_TEMPLATE_MAP.put("200702", "ZNDFJFTemplate.html"); // 电费缴费 TXN_TEMPLATE_MAP.put("200907", "ZNGDJFTemplate.html"); // 广电缴费 TXN_TEMPLATE_MAP.put("201503", "ZNSFJFTemplate.html"); // 水费缴费 TXN_TEMPLATE_MAP.put("2006042", "XDTQHBTemplate.html"); // 提前还本 } }

这种集中式映射方式极大提升了维护效率。当新增一种交易类型时,只需添加一条映射记录,无需修改主流程代码。


核心工具类实现

ElecBillCreateUtils.java—— 主要功能集合

该工具类集成了模板渲染、图像转换、编码处理等关键操作。

package com.elec.pdf.util; import com.google.zxing.BarcodeFormat; import com.google.zxing.EncodeHintType; import com.google.zxing.qrcode.QRCodeWriter; import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; import com.itextpdf.text.pdf.BaseFont; import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateExceptionHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xhtmlrenderer.pdf.ITextFontResolver; import org.xhtmlrenderer.pdf.ITextRenderer; import uk.ac.cam.cl.mgk25.jbigkit.JBIG; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.*; import java.util.Hashtable; import java.util.Map; public class ElecBillCreateUtils { private static final Logger log = LoggerFactory.getLogger(ElecBillCreateUtils.class); private static Configuration fmConfig; static { fmConfig = new Configuration(Configuration.VERSION_2_3_31); try { fmConfig.setClassForTemplateLoading(ElecBillCreateUtils.class, "/templates"); fmConfig.setDefaultEncoding("UTF-8"); fmConfig.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); } catch (Exception e) { log.error("初始化 Freemarker 配置失败", e); } } /** * 渲染 Freemarker 模板为 HTML 字符串 */ public static String renderHtml(Map<String, Object> data, String templateName) throws Exception { Writer out = new StringWriter(); Template template = fmConfig.getTemplate(templateName); template.process(data, out); return out.toString(); } /** * 将 HTML 内容转为 PDF 字节流 */ public static byte[] htmlToPdf(String htmlContent, String fontPath) throws Exception { ByteArrayOutputStream os = new ByteArrayOutputStream(); ITextRenderer renderer = new ITextRenderer(); ITextFontResolver fontResolver = renderer.getFontResolver(); // 注册中文字体 fontResolver.addFont(fontPath, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); renderer.setDocumentFromString(htmlContent); renderer.layout(); renderer.createPDF(os); renderer.finishPDF(); return os.toByteArray(); } /** * JBIG 压缩数据还原为 BMP 文件 */ public static void jbigToBmp(byte[] jbigData, String outputPath) throws IOException { File tmpFile = new File(outputPath + Constants.PDF_CONFIG.JBIG_SUFFIX); try (FileOutputStream fos = new FileOutputStream(tmpFile)) { fos.write(jbigData); } try { JBIG.jbg2bmp(tmpFile.getAbsolutePath(), outputPath + Constants.PDF_CONFIG.BMP_SUFFIX); } catch (Exception e) { throw new IOException("JBIG 解码失败", e); } finally { if (tmpFile.exists()) tmpFile.delete(); } } /** * BMP 转 JPG(减小体积,便于后续展示) */ public static void bmpToJpg(String bmpPath, String jpgPath) throws IOException { BufferedImage image = ImageIO.read(new File(bmpPath)); BufferedImage converted = new BufferedImage( image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB); converted.getGraphics().drawImage(image, 0, 0, null); ImageIO.write(converted, "jpg", new File(jpgPath)); new File(bmpPath).delete(); // 删除中间文件 } /** * 十六进制字符串转字节数组 */ public static byte[] hexToBytes(String hexStr) { int len = hexStr.length(); byte[] bytes = new byte[len / 2]; for (int i = 0; i < len; i += 2) { bytes[i / 2] = (byte) ((Character.digit(hexStr.charAt(i), 16) << 4) + Character.digit(hexStr.charAt(i + 1), 16)); } return bytes; } /** * 生成二维码并返回 Base64 编码 */ public static String generateQrCodeBase64(String content) throws Exception { QRCodeWriter qrWriter = new QRCodeWriter(); Hashtable<EncodeHintType, Object> hints = new Hashtable<>(); hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); hints.put(EncodeHintType.MARGIN, 1); BitMatrix matrix = qrWriter.encode(content, BarcodeFormat.QR_CODE, 100, 100, hints); ByteArrayOutputStream baos = new ByteArrayOutputStream(); MatrixToImageWriter.writeToStream(matrix, "PNG", baos); return "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(baos.toByteArray()); } }

其中几个细节值得强调:

  • Freemarker 初始化:建议在静态块中一次性加载模板目录,避免每次请求重复构建Configuration
  • 字体注册策略BaseFont.IDENTITY_H支持 Unicode 横向书写,适用于中文;NOT_EMBEDDED表示不嵌入字体子集,节省PDF体积。
  • JBIG 处理机制:由于 JBIGKit 底层调用本地命令行工具,必须先写入临时.jbig文件再执行转换,因此务必做好异常清理。

服务层实现

CreatePdfService是核心业务入口,负责组装数据模型并触发 PDF 生成。

@Service public class CreatePdfService { private static final Logger log = LoggerFactory.getLogger(CreatePdfService.class); public byte[] generatePdf(TxnReceipt receipt) throws Exception { String txnType = receipt.getTxnTypeCode(); String templateName = Constants.TXN_TEMPLATE_MAP.get(txnType); if (templateName == null) { throw new IllegalArgumentException("未找到交易类型对应的模板:" + txnType); } Map<String, Object> dataModel = buildDataModel(receipt); String html = ElecBillCreateUtils.renderHtml(dataModel, templateName); return ElecBillCreateUtils.htmlToPdf(html, Constants.PDF_CONFIG.PDF_FONT_PATH); } private Map<String, Object> buildDataModel(TxnReceipt receipt) throws Exception { Map<String, Object> model = new HashMap<>(); model.putAll(receipt.toMap()); // 嵌入头部 Logo String logoBase64 = java.util.Base64.getEncoder().encodeToString( readImage(Constants.PDF_CONFIG.PDF_HEADER_LOGO)); model.put("headImg", Constants.PDF_CONFIG.IMG_BASE64_HEADER + logoBase64); // 生成二维码 if (receipt.getQrCode() != null && !receipt.getQrCode().isEmpty()) { model.put("qrCode", ElecBillCreateUtils.generateQrCodeBase64(receipt.getQrCode())); } // 处理签名图像 if (receipt.getSignHex() != null && !receipt.getSignHex().isEmpty()) { byte[] jbigBytes = ElecBillCreateUtils.hexToBytes(receipt.getSignHex()); String baseDir = Constants.PDF_CONFIG.PDF_FILE_PATH; String tempFileName = receipt.getOrderId(); ElecBillCreateUtils.jbigToBmp(jbigBytes, baseDir + tempFileName); String jpgPath = baseDir + tempFileName + Constants.PDF_CONFIG.JPG_SUFFIX; ElecBillCreateUtils.bmpToJpg(baseDir + tempFileName + Constants.PDF_CONFIG.BMP_SUFFIX, jpgPath); byte[] jpgBytes = readImage(jpgPath); model.put("signBase64", Constants.PDF_CONFIG.IMG_BASE64_HEADER + java.util.Base64.getEncoder().encodeToString(jpgBytes)); deleteTempFiles(baseDir, tempFileName); } // 金额格式化 if (model.containsKey("amount")) { Double amt = Double.parseDouble(model.get("amount").toString()); model.put("amount", String.format("RMB %,.2f", amt)); } return model; } private byte[] readImage(String path) throws IOException { return org.apache.commons.io.FileUtils.readFileToByteArray(new File(path)); } private void deleteTempFiles(String dir, String name) { Arrays.asList(".jbig", ".bmp", ".jpg").forEach(ext -> { File f = new File(dir + name + ext); if (f.exists()) f.delete(); }); } }

这里有几个工程实践建议:

  • 金额单位处理:通常前端传的是“分”为单位的整数,应在服务层统一转为“元”并格式化显示;
  • 空值判断:模板中应使用<#if var??>判断变量是否存在,防止空指针异常;
  • 异步清理:对于高频交易系统,临时文件删除动作可放入线程池或定时任务中批量执行,减轻主线程负担。

控制器层接口暴露

控制器提供标准 RESTful 接口,支持预览与下载两种模式:

@RestController @RequestMapping("/pdf") public class PdfGenerateController { @Autowired private CreatePdfService pdfService; @PostMapping("/generate") public ResponseEntity<byte[]> generatePdf(@RequestBody TxnReceipt receipt, @RequestParam String opFlag) { try { byte[] pdfBytes = pdfService.generatePdf(receipt); HttpHeaders headers = new HttpHeaders(); String filename = receipt.getOrderId() + ".pdf"; if (Constants.PDF_CONFIG.OP_DOWNLOAD.equals(opFlag)) { headers.setContentType(MediaType.APPLICATION_PDF); headers.setContentDispositionFormData("attachment", filename); } else { headers.setContentType(MediaType.APPLICATION_PDF); } return new ResponseEntity<>(pdfBytes, headers, HttpStatus.OK); } catch (Exception e) { log.error("PDF生成失败", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } }

客户端可通过设置opFlag=02触发浏览器自动下载,而01则用于内嵌预览(如 H5 页面 iframe 展示)。


HTML 模板设计示例

模板采用简洁的 HTML + 内联 CSS 结构,保证样式在 PDF 渲染中的稳定性:

<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> body { font-family: 'SimHei', sans-serif; font-size: 12px; margin: 20px; } .header { text-align: center; margin-bottom: 10px; } .logo { width: 100px; } .table { width: 100%; border-collapse: collapse; margin-top: 10px; } td { padding: 4px 0; vertical-align: top; } .right { text-align: right; } .dashed { border-bottom: 1px dashed #000; } .center { text-align: center; } .signature-box { height: 60px; border: 1px solid #ccc; margin: 10px 0; } .qrcode { width: 80px; } </style> </head> <body> <div class="header"> <img src="${headImg}" alt="Logo" class="logo"/> <h3>贵州农信 · 助农取款凭证</h3> </div> <table class="table"> <tr><td>商户名称:</td><td class="right">${merName}</td></tr> <tr><td>操作员:</td><td class="right">${operatorNo}</td></tr> <tr class="dashed"><td>卡号:</td><td class="right">${cardNo?substring(0,4)}****${cardNo?substring(cardNo?length-4)}</td></tr> <tr><td>交易时间:</td><td class="right">${datetime}</td></tr> <tr><td>交易金额:</td><td class="right">${amount}</td></tr> </table> <div class="signature-box"> <strong>客户签名:</strong><br/> <#if signBase64??> <img src="${signBase64}" width="120" /> </#if> </div> <#if qrCode??> <div class="center"> <img src="${qrCode}" class="qrcode"/> <div style="font-size:10px;">扫码查询交易记录</div> </div> </#if> <div style="text-align:center;margin-top:20px;font-size:10px;color:#666;"> 请妥善保管此凭证,如有疑问请联系客服。 </div> </body> </html>

Freemarker 支持丰富的表达式语法,例如:
-${cardNo?substring(0,4)}****${cardNo?substring(cardNo?length-4)}实现卡号脱敏;
-<#if signBase64??>安全判空,防止占位符暴露。


性能优化要点

优化方向实践建议
JBIG 压缩效果实测 BMP 签名从平均 250KB 压至 2.5KB,节省 99% 存储空间
模板缓存机制Freemarker 自带模板缓存,合理配置setTemplateUpdateDelay可提升命中率
字体复用simhei.ttf在应用启动时加载一次即可,避免重复注册
临时文件生命周期控制所有中间文件必须及时清理,推荐配合try-finally或 AOP 实现
并发处理能力高峰期可引入线程池隔离 PDF 渲染任务,防止单点阻塞

特别提醒:JBIG 解压过程涉及磁盘 IO 和外部命令调用,属于相对耗时操作。在 QPS 较高的场景下,建议前置压缩步骤由终端完成,后端仅做轻量解码。


测试验证与部署建议

以下是一个典型的单元测试案例:

@Test public void testGeneratePdf() throws Exception { TxnReceipt receipt = new TxnReceipt(); receipt.setOrderId("202405200001"); receipt.setTxnTypeCode("200401"); receipt.setMerName("张三家小卖部"); receipt.setOperatorNo("OP001"); receipt.setCardNo("6217791234567890"); receipt.setDatetime("2024-05-20 14:30:22"); receipt.setAmount("15000"); receipt.setQrCode("https://api.bank.com/query?id=202405200001"); receipt.setSignHex("00020100000006560000018c..."); // 模拟 Hex 数据 byte[] pdfBytes = pdfService.generatePdf(receipt); assertNotNull(pdfBytes); assertTrue(pdfBytes.length > 1024); Files.write(Paths.get("test_receipt.pdf"), pdfBytes); }

部署时建议:
- 使用 Docker 容器化部署,固定字体路径与临时目录;
- 配置日志监控,捕获JBIG.jbg2bmp执行失败等底层异常;
- 生产环境开启模板缓存并禁用热更新,提升性能稳定性。


这种融合模板驱动与图像压缩的设计思路,已在多个省级农信社项目中成功落地。它不仅解决了传统方案中“大图难存、中文难显、模板难管”的痛点,也为未来接入数字签名、区块链存证等功能预留了良好扩展性。随着边缘计算能力的增强,甚至可以考虑将部分渲染任务下沉至终端侧,进一步释放服务端压力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询