基于Spring Boot与PDFBox为多签名PDF添加含文字图片的Parafe域
实现类似DocuSign的Parafe域(PDFBox 2.0.28 + Spring Boot)
核心逻辑
DocuSign的Parafe域本质是带复合可视化外观的PDF签名域,同时承载签名图像、签名人信息(姓名、日期)等内容。结合你已实现多用户签名不破坏完整性的基础,核心要做的是:
- 为签名域生成包含图片+文字的自定义外观
- 始终使用PDF增量更新机制保存修改,避免覆盖已有签名
关键实现步骤
- 定位/创建签名域:从PDF的AcroForm中获取目标签名域,若不存在则动态创建并绑定到指定页面位置。
- 生成复合外观流:通过PDFBox的
PDAppearanceStream绘制签名图片、姓名、日期等元素,组合成Parafe域的可视化内容。 - 增量保存文档:调用
saveIncremental方法保存修改,确保原有签名的完整性不受影响。
代码示例
1. Parafe域处理工具类
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.util.Matrix; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.util.Calendar; public class ParafeSignatureHandler { /** * 填充Parafe域并生成文字+图片的签名外观 * @param doc 待处理PDF文档 * @param fieldName 目标签名域名称 * @param signImagePath 签名图片本地路径 * @param signerName 签名人姓名 * @param signDate 签名日期 * @throws IOException */ public void populateParafeField(PDDocument doc, String fieldName, String signImagePath, String signerName, Calendar signDate) throws IOException { PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(); if (acroForm == null) { throw new IllegalArgumentException("目标PDF未包含AcroForm表单"); } // 获取或创建签名域 PDSignatureField signatureField = (PDSignatureField) acroForm.getField(fieldName); if (signatureField == null) { // 动态创建签名域(示例绑定到第一页,位置需根据实际需求调整) PDPage targetPage = doc.getPage(0); signatureField = new PDSignatureField(acroForm); signatureField.setPartialName(fieldName); PDAnnotationWidget widget = signatureField.getWidgets().get(0); // 设置域位置:x1,y1,x2,y2(PDF坐标原点在左下角,单位为点) widget.setRectangle(new float[]{100, 100, 350, 160}); targetPage.getAnnotations().add(widget); acroForm.getFields().add(signatureField); } // 生成Parafe复合外观 generateParafeAppearance(signatureField, signImagePath, signerName, signDate); } /** * 生成包含图片和文字的签名外观 */ private void generateParafeAppearance(PDSignatureField signatureField, String signImagePath, String signerName, Calendar signDate) throws IOException { PDAnnotationWidget widget = signatureField.getWidgets().get(0); float[] rect = widget.getRectangle().getRectangle(); float width = rect[2] - rect[0]; float height = rect[3] - rect[1]; // 创建外观流 PDAppearanceDictionary appearanceDict = new PDAppearanceDictionary(); PDAppearanceStream appearanceStream = new PDAppearanceStream(signatureField.getAcroForm().getDocument()); appearanceStream.setBBox(widget.getRectangle()); appearanceStream.setMatrix(Matrix.getTranslateInstance(rect[0], rect[1])); try (var contentStream = appearanceStream.createOutputStream()) { // 1. 绘制签名图片(自适应缩放) PDImageXObject signImage = PDImageXObject.createFromFile(signImagePath, signatureField.getAcroForm().getDocument()); float imgScale = Math.min(width * 0.55f / signImage.getWidth(), height * 0.9f / signImage.getHeight()); float imgX = 15; float imgY = (height - signImage.getHeight() * imgScale) / 2; contentStream.drawImage(signImage, imgX, imgY, signImage.getWidth() * imgScale, signImage.getHeight() * imgScale); // 2. 绘制签名人姓名 contentStream.setFont(PDType1Font.HELVETICA_BOLD, 12); contentStream.beginText(); contentStream.newLineAtOffset(imgX + signImage.getWidth() * imgScale + 20, height - 25); contentStream.showText("签名人: " + signerName); contentStream.endText(); // 3. 绘制签名日期 contentStream.setFont(PDType1Font.HELVETICA, 10); contentStream.beginText(); contentStream.newLineAtOffset(imgX + signImage.getWidth() * imgScale + 20, height - 45); contentStream.showText("签署日期: " + String.format("%tF %tT", signDate, signDate)); contentStream.endText(); } appearanceDict.setNormalAppearance(appearanceStream); widget.setAppearance(appearanceDict); } /** * 增量保存PDF,确保原有签名不被破坏 */ public void saveIncremental(PDDocument doc, File outputFile) throws IOException { try (FileOutputStream fos = new FileOutputStream(outputFile)) { doc.saveIncremental(fos); } } }
2. Spring Boot中结合RabbitMQ消费逻辑
import org.apache.pdfbox.pdmodel.PDDocument; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; import java.io.File; import java.util.Calendar; @Component public class SignatureRequestConsumer { private final ParafeSignatureHandler signatureHandler = new ParafeSignatureHandler(); @RabbitListener(queues = "signature-request-queue") public void handleParafeSignatureRequest(SignatureRequest request) { try { // 加载源PDF(实际场景可从OSS/本地存储获取) File sourcePdf = new File(request.getSourcePdfPath()); try (PDDocument doc = PDDocument.load(sourcePdf)) { // 填充Parafe域 signatureHandler.populateParafeField( doc, request.getParafeFieldName(), request.getSignImagePath(), request.getSignerName(), Calendar.getInstance() ); // 增量保存到目标路径 File targetPdf = new File(request.getTargetPdfPath()); signatureHandler.saveIncremental(doc, targetPdf); } } catch (Exception e) { // 实际场景需添加日志记录、重试机制等 e.printStackTrace(); } } // 签名请求DTO public static class SignatureRequest { private String sourcePdfPath; private String parafeFieldName; private String signImagePath; private String signerName; private String targetPdfPath; // Getters & Setters public String getSourcePdfPath() { return sourcePdfPath; } public void setSourcePdfPath(String sourcePdfPath) { this.sourcePdfPath = sourcePdfPath; } public String getParafeFieldName() { return parafeFieldName; } public void setParafeFieldName(String parafeFieldName) { this.parafeFieldName = parafeFieldName; } public String getSignImagePath() { return signImagePath; } public void setSignImagePath(String signImagePath) { this.signImagePath = signImagePath; } public String getSignerName() { return signerName; } public void setSignerName(String signerName) { this.signerName = signerName; } public String getTargetPdfPath() { return targetPdfPath; } public void setTargetPdfPath(String targetPdfPath) { this.targetPdfPath = targetPdfPath; } } }
重要注意事项
- 必须使用增量保存:绝对不能用
doc.save(),必须调用saveIncremental,否则会完全重写PDF,导致原有签名全部失效。 - 域权限配置:确保AcroForm设置
acroForm.setNeedAppearances(false),避免PDF阅读器自动重置外观;同时签名域需设置正确的权限标志(如允许填写)。 - 字体与图片兼容性:使用嵌入字体避免显示异常,图片支持PNG、JPG等格式,需确保图片路径可访问。
- 多签名顺序:若需多个用户依次填写Parafe域,需保证每个用户的操作都基于上一次增量更新后的PDF文件。
内容的提问来源于stack exchange,提问作者rahim soufiane
相关产品推荐
相关产品推荐

