背景
PDF电子签章可以应用于数字化应用系统中,比如OA系统、ERP系统集成,单独抽出后端工具类可以直接在工作流引擎结束后调用,避免冗余处理,前端可以实现文件预览+签章模板坐标选取,后端实现坐标+实际章加盖,根据数字章的预设大小可以等比例设置对应签章,刚好涉及到此快业务,抽出工具类分享给大家
整体流程
* 加载PDF文档
* 计算印章位置和尺寸
* 创建图章注释对象
* 创建外观流并绘制印章图像
* 配置外观字典
* 将注释添加到页面
* 保存文档
依赖引入
java
<!--alibaba json-->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.40</version>
</dependency>
<!--pdfbox-->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.29</version>
</dependency>
示例章

程序展示
以下展示的是测试功能,需要自己嵌入业务中处理
java
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.Data;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationRubberStamp;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.springframework.util.StringUtils;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* @author 忧郁的Mr.Li
*/
public class PdfSignUtil {
private static String signImageFileKey = "core_sign_image_key";//图片key
/**
* @author 忧郁的Mr.Li
* 测试类
*/
public static void main(String[] args) throws Exception {
//签章文件
String inPdfFile = "C:\\xxxx\\xxxx\\xxxx\\测试文件\\测试PDF文件.pdf";
//输出路径
String outpdffile = "C:\\xxxx\\xxxx\\xxxx\\测试文件\\测试PDF文件_out.pdf";
//实际签章文件
Map<String, File> imageFiles = new HashMap<>();
File file = new File("C:\\xxxx\\xxxx\\xxxx\\同济xxxx\\hints\\company.png");
imageFiles.put("company",file);
//签章坐标信息(包含章id、章类型、第几页、详细坐标、)
JSONArray jsonArray = new JSONArray();
JSONObject jsonObject = new JSONObject();
jsonObject.put("rightTopB",107.32477529583333);//页面左下角到印章右上角的Y轴坐标
jsonObject.put("leftBottomL",36.229693225);//页面左下角到印章左下角的X轴坐标
jsonObject.put("rightTopL",76.18177655833334);//页面左下角到印章右上角的X轴坐标
jsonObject.put("leftBottomB",67.3726919625);//页面左下角到印章左下角的Y轴坐标
jsonObject.put("page",1);//第页
jsonObject.put("mainid",202); //用来定义章的大小关联配置表中的章的大小缩放
jsonObject.put("type","company"); //用来获取实际签章文件,如果同一类型的章需要不同实际章文件,这里可以设置实际文件唯一标识,和imageFiles的Key对应即可
JSONObject jsonObject1 = new JSONObject();
jsonObject1.put("rightTopB",107.32477529583333);
jsonObject1.put("leftBottomL",150.229693225);
jsonObject1.put("rightTopL",76.18177655833334);
jsonObject1.put("leftBottomB",130.3726919625);
jsonObject1.put("page",1);
jsonObject1.put("mainid",202);
jsonObject1.put("type","company");
jsonArray.add(jsonObject);
jsonArray.add(jsonObject1);
//文档密码设置的话就可以加密签章完成的文件
//-------
//调用签章程序
File file1 = addPdfSignBySignatures(inPdfFile, outpdffile, imageFiles, jsonArray, "");
System.out.println("签章完成:生成文件"+file1.getPath());
}
/**
* @author 忧郁的Mr.Li
* @param :获取(创建)临时文件夹,后续换成自己的
*/
public static String getTempPathByDayMillis() {
String tempDir = System.getProperty("java.io.tmpdir");
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyyMMdd");
String dateStr = sdf.format(new java.util.Date());
String path = tempDir + File.separator + "pdfsign" + File.separator + dateStr;
File dir = new File(path);
if (!dir.exists()) {
dir.mkdirs();
}
return path;
}
/**
* @author 忧郁的Mr.Li
* pdf加图片章(根据平台签字工具的配置信息 给pdf文件加章)
*
* @param InPdfFile 签章文件
* @param outpdffile 输出路径
* @param imageFiles 实际签章文件
* @param signatures 签章坐标信息(包含章id、章类型、第几页、详细坐标、)
* @param password 文档密码
* @throws Exception
*/
public static File addPdfSignBySignatures(String InPdfFile, String outpdffile, Map<String, File> imageFiles, JSONArray signatures, String password) throws Exception {
/**
* 这一行代码确实会将PDF文件加载到内存中
* 当调用`PDDocument.load(new File(InPdfFile))`时,PDFBox会执行以下操作:
*
* 1. **读取文件内容**: 从磁盘读取整个PDF文件的二进制数据
* 2. **解析PDF结构**: 解析PDF的文档结构、页面对象、字体、图像等资源
* 3. **构建内存对象树**: 在JVM堆内存中创建完整的文档对象模型,包括所有页面、内容流、注释等
* 4. **返回文档对象**: 返回一个PDDocument实例,包含所有已解析的内容
* **内存占用特点:**
* - 对于小文件(几MB),内存占用相对可控
* - 对于大文件(几十MB或上百MB),可能会占用大量堆内存
* - PDF中的嵌入字体、高分辨率图片等资源会显著增加内存消耗
* - 通常内存占用会是文件大小的数倍
* **优化建议:**
* 如果您的应用场景需要处理大型PDF文件,可以考虑:
* 1. 使用带内存限制参数的load方法,设置最大内存使用量
* 2. 启用临时文件缓存,让PDFBox将部分数据写入磁盘而非全部保留在内存
* 3. 处理完成后立即关闭文档对象,释放内存资源
* 4. 调整JVM堆大小参数,确保有足够的内存空间
* 您当前的代码使用了try-with-resources语法,这能确保文档使用完毕后自动关闭并释放内存,这是正确的做法。
*/
try (PDDocument document = PDDocument.load(new File(InPdfFile))) {
/**
* 这行代码的作用是移除PDF文档的所有安全限制。
* 具体功能说明:
* 解除权限限制: 如果PDF文件设置了权限密码(用户密码或所有者密码),这行代码会告诉PDFBox在保存时移除这些安全限制
* 必须在保存前设置: 这个设置只在调用document.save()方法保存文档时才生效,它不会立即改变当前加载的文档状态
* 需要正确密码: 要成功移除安全限制,加载文档时必须提供正确的打开密码或所有者密码。如果文档有密码保护但加载时未提供密码,这行代码可能无法正常工作
* 典型应用场景:
* 对受保护的PDF进行编辑后,保存为无密码版本
* 移除打印、复制、修改等权限限制
* 在处理加密PDF前解除其安全设置
* 注意事项:
* 这行代码不会移除打开密码本身,只是移除各种操作权限的限制
* 如果PDF有打开密码,仍需在加载时提供密码
* 只有在保存新文档时,安全设置的变更才会体现出来
* 对于没有安全设置的PDF,调用此方法不会产生任何影响
*/
document.setAllSecurityToBeRemoved(true);
// 获取平台签章配置
Map<Object, PdfSealConfig> pfsealconfig = getPfSealConfigs();
//判断签章信息是否存在
if (signatures != null && signatures.size() >= 0) {
//开始循环签章信息
for (int i = 0; i < signatures.size(); i++) {
//从签章信息中取出一个签章坐标信息
JSONObject itemconfig = signatures.getJSONObject(i);
//获取签章信息中的签章主键
int mainid = itemconfig.getInteger("mainid");
//获取签章信息中的类型
String uniquebykey = itemconfig.getString("type");
//根据主键获取印章的详细配置信息
PdfSealConfig configMap = pfsealconfig.get(mainid);
//获取当前印章的类型
String typecode = configMap.getType();
//印章实际图片
File imageFile = null;
if (!StringUtils.isEmpty(uniquebykey) && !uniquebykey.equals(typecode)) {
imageFile = imageFiles.get(uniquebykey);
} else {
imageFile = imageFiles.get(typecode);
}
if (imageFile == null) {
imageFile = imageFiles.get(signImageFileKey);
}
if (imageFile == null || !imageFile.exists()) {
continue;
}
// 获取页面
Integer page = itemconfig.getInteger("page");
if (page < 1 || page > document.getNumberOfPages()) {
continue;
}
PDPage targetPage = document.getPage(page - 1);
PDRectangle box = targetPage.getMediaBox();
//偏移坐标值
float lowerLeftX = box.getLowerLeftX();
float lowerLeftY = box.getLowerLeftY();
float width = box.getWidth();
float height = box.getHeight();
// 获取签章配置尺寸
double imagewidth = configMap.getWidth();
double imagheight = configMap.getWidth();
// 计算位置以图片左下角的位置为主
double leftBottomL = itemconfig.getDouble("leftBottomL");
double leftBottomB = itemconfig.getDouble("leftBottomB");
// 转换为点单位
float apartRight = (float) ((leftBottomL * 72) / 25.4f);
float apartFloor = (float) ((leftBottomB * 72) / 25.4f);
float x, y;
//获取PDF页面的旋转角度
int rotation = targetPage.getRotation();
switch (rotation) {
case 90:
x = lowerLeftX - apartFloor + width - (float) imagheight * 72 / 25.4f;
y = lowerLeftY + apartRight;
break;
case 180:
x = lowerLeftX + width - apartRight - (float) imagewidth * 72 / 25.4f;
y = lowerLeftY + height - apartFloor - (float) imagheight * 72 / 25.4f;
break;
case 270:
x = lowerLeftX + apartFloor;
y = lowerLeftY - apartRight - (float) imagewidth * 72 / 25.4f + height;
break;
default:
x = lowerLeftX + apartRight;
y = lowerLeftY + apartFloor;
break;
}
// 转换尺寸为像素
float imagewidthpx = (float) convertMmToPixels(imagewidth);
float imagheightpx = (float) convertMmToPixels(imagheight);
// 添加注释签章
addStampAnnotation(document, targetPage, imageFile, x, y, imagewidthpx, imagheightpx, rotation);
}
}
// 设置密码保护
if (!StringUtils.isEmpty(password)) {
AccessPermission ap = new AccessPermission();
ap.setCanPrint(true);
ap.setCanExtractForAccessibility(false);
ap.setCanExtractContent(false); // 允许复制内容
ap.setCanPrintDegraded(true); // 允许降级打印
ap.setCanModify(false); // 禁止修改文档内容
ap.setCanFillInForm(false); // 禁止填写表单
ap.setCanModifyAnnotations(false); // 禁止修改注释
ap.setCanAssembleDocument(false); // 禁止文档组装
StandardProtectionPolicy spp = new StandardProtectionPolicy(password, "", ap);
spp.setEncryptionKeyLength(128);
document.protect(spp);
}
// 保存文档
document.save(outpdffile);
}
return new File(outpdffile);
}
/**
* @author 忧郁的Mr.Li
* @param document: PDF在内存中的对象
* @param targetPage: 待操作的单独页面
* @param imageFile: 实际印章图片
* @param x: 页面左下角到印章右下角的X轴坐标
* @param y: 页面左下角到印章右下角的Y轴坐标
* @param width: 印章的宽度
* @param height: 印章的高度
* @param rotation: 旋转角度
*/
private static void addStampAnnotation(PDDocument document, PDPage targetPage, File imageFile,
float x, float y, float width, float height, int rotation) throws Exception {
/**
* 创建一个PDF的图章注释
* 橡胶图章注释的特点
* 视觉呈现: 可以在PDF页面上显示图像内容,非常适合用于电子签章场景
* 非破坏性: 作为注释层添加,不会修改PDF的原始内容流,便于后续移除或修
* 可交互: 用户可以点击查看注释属性,某些阅读器中还可以移动或删除
* 标准支持: 符合PDF规范,大多数PDF阅读器都能正确显示
*/
PDAnnotationRubberStamp stampAnnotation = new PDAnnotationRubberStamp();
/**
* 在页面旋转90度或270度时,交换印章的宽度和高度值。
* 原始状态(0度): 页面是横向或纵向的正常显示,宽度和高度按常规理解
* 旋转90度或270度后: 页面相当于从横变竖或从竖变横,原来的宽度变成了视觉上的高度,原来的高度变成了视觉上的宽度
* 比如说:印章尺寸是40mm宽、30mm高:
* 在0度页面上: 印章占据40mm的水平空间和30mm的垂直空间
* 在90度旋转页面上: 如果不交换宽高,印章会按40mm垂直、30mm水平放置,导致印章变形或位置错误
* 交换后: 印章会正确地在视觉上占据40mm水平、30mm垂直的空间
* 为什么只在90度和270度时交换:
* 0度: 页面正常,无需调整
* 180度: 页面虽然倒置,但宽度和高度的方向关系没有改变,只是起点位置变了(从左下角变成右上角),所以不需要交换宽高
* 90度和270度: 页面的横纵方向完全互换,必须交换宽高才能保持印章的正确比例和视觉大小
*/
if (rotation == 90 || rotation == 270) {
float temp = width;
width = height;
height = temp;
}
/**
* 创建一个矩形区域对象,用于定义印章在PDF页面上的精确位置和尺寸。
* PDRectangle构造函数接收四个参数:
* x: 矩形左下角的X坐标(距离页面左边缘的距离)
* y: 矩形左下角的Y坐标(距离页面底边缘的距离)
* width: 矩形的宽度
* height: 矩形的高度
* 这个矩形对象定义了印章注释的边界框(Bounding Box),决定了:
* 位置: 印章出现在页面的哪个位置(x和y坐标)
* 大小: 印章显示为多大(width和height)
* 显示区域: PDF阅读器会在这个矩形区域内渲染印章图像
* 单位说明:
* 这里的坐标和尺寸单位都是"点"(point),这是PDF的标准单位:
* 1点 = 1/72英寸
* 1英寸 = 25.4毫米
* 所以1毫米 ≈ 2.8346点
* 这也是为什么前面代码中要将毫米值转换为点值的原因。
*/
PDRectangle position = new PDRectangle(x, y, width, height);
/**
* 将之前创建的矩形位置对象设置给图章注释,确定印章在页面上的显示区域。
* 具体说明:
* setRectangle()方法将position矩形对象(包含x、y坐标和宽高信息)绑定到stampAnnotation图章注释对象上。
* 这一步的重要性:
* 这是配置图章注释的关键步骤之一,它告诉PDF阅读器:
* 印章应该出现在页面的哪个位置
* 印章应该显示为多大的尺寸
* 印章图像的渲染边界在哪里
* 完整的配置流程:
* 在添加印章注释时,通常需要设置多个属性:
* 设置位置矩形(setRectangle) - 确定位置和大小
* 设置图像资源 - 确定显示什么内容
* 设置其他可选属性(如名称、标志等)
* 只有完成了这些配置后,才能将注释添加到页面上。如果不调用这个方法,图章注释就没有位置信息,无法正确显示。
*/
stampAnnotation.setRectangle(position);
/**
* 设置图章注释为"可打印"状态,确保印章在打印PDF时能够显示出来。
* setPrinted(true)方法设置注释的"Printed"标志位,这个标志控制注释在打印时的行为:
* 设置为true: 注释(印章)会在打印PDF文档时被输出到纸张上
* 设置为false或不设置: 注释只在屏幕上可见,但打印时会隐藏
* 在签章场景中的重要性:
* 对于电子签章应用,这通常是必须设置的,因为:
* 法律效力: 签章文档通常需要打印存档或提交纸质版,如果印章不打印出来,就失去了签章的意义
* 业务需求: 大多数签章场景要求屏幕显示和打印输出保持一致
* 用户体验: 用户期望看到的印章效果与实际打印结果一致
* PDF规范中定义了多种注释显示标志,包括:
* Printed: 控制打印时是否显示
* Hidden: 控制是否在界面上隐藏
* Invisible: 控制是否不可见但仍可交互
* ReadOnly: 控制是否只读
* 对于签章这种需要持久化呈现的场景,设置Printed为true是标准做法
*/
stampAnnotation.setPrinted(true);
//获取外观流
PDAppearanceStream appearanceStream = createAppearanceStream(document, imageFile, width, height, rotation);
/**
* 是创建一个外观字典对象,用于管理图章注释的不同状态下的视觉表现。
* PDAppearanceDictionary是PDFBox中表示PDF外观字典的类。在PDF规范中,外观字典是一个容器,可以存储注释在多种状态下的外观流。
* 外观字典的作用:
* 外观字典可以包含以下几种外观:
* 正常外观(N): 注释在常规状态下如何显示(最常用)
* 悬停外观(R): 鼠标悬停在注释上时的显示效果(可选)
* 按下外观(D): 鼠标点击注释时的显示效果(可选)
* 在印章场景中的应用:
* 对于电子签章这种静态注释,通常只需要设置正常外观(N):
* 印章始终以相同的方式显示
* 不需要交互式的视觉效果变化
* 简化配置,减少PDF文件大小
* 为什么需要外观字典:
* PDF采用分层设计:
* 注释对象: 存储元数据和位置信息
* 外观字典: 管理不同状态的视觉表现
* 外观流: 具体的绘制指令和图像数据
* 外观字典作为中间层,提供了灵活性:
* 可以为同一注释定义多种视觉状态
* PDF阅读器根据用户交互选择合适的外观
* 支持渐进式渲染和缓存优化
*/
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
//将之前创建的外观流设置为正常状态的外观
appearance.setNormalAppearance(appearanceStream);
//将整个外观字典设置给图章注释对象
stampAnnotation.setAppearance(appearance);
//将配置完成的图章注释添加到目标PDF页面的注释列表中,使其正式成为页面的一部分。
targetPage.getAnnotations().add(stampAnnotation);
}
/**
* @author 忧郁的Mr.Li
* @param document: PDF文件对象
* @param imageFile: 印章实际图片的文件
* @param width: 印章宽度
* @param height: 印章高度
* @param pageRotation: 页面旋转角度
* 封装整个印章图像转换为PDF外观流的完整流程。
*/
private static PDAppearanceStream createAppearanceStream(PDDocument document, File imageFile, float width, float height, int pageRotation) throws Exception {
//根据页面对印章进行旋转处理
File image = rotateImageFile(imageFile, 360 - pageRotation);
/**
* 创建一个空的PDF外观流对象,用于承载印章图像的视觉内容。
* PDAppearanceStream是PDFBox库中的一个类,代表PDF规范中的"外观流"(Appearance Stream)。构造函数传入document参数,将这个外观流关联到指定的PDF文档中。
* 外观流的作用:
* 外观流是PDF注释的"渲染模板",它定义了注释在页面上应该如何显示。对于图章注释来说,外观流包含:
* 印章图像数据
* 图像的绘制位置和尺寸
* 可能的变换操作(缩放、旋转等)
* 其他图形状态信息
* 为什么需要创建外观流:
* PDF的注释机制采用分离设计:
* 注释对象: 存储注释的元数据(位置、类型、属性等)
* 外观流: 存储注释的实际视觉内容
* 这种设计的好处是:
* 同一个注释可以有多个外观(如正常状态、鼠标悬停状态)
* 阅读器可以选择性地渲染外观
* 便于缓存和复用
* 后续操作:
* 创建这个空的外观流对象后,后续代码会:
* 获取其内容流(PDPageContentStream)
* 将旋转后的印章图像绘制到内容流中
* 设置边界框(BBox)定义绘制区域
* 最后将完成的外观流设置给图章注释
* 这是构建印章视觉效果的核心步骤之一。
*/
PDAppearanceStream appearanceStream = new PDAppearanceStream(document);
/**
* 创建一个边界框矩形对象,用于定义外观流的绘制区域大小
* PDRectangle构造函数接收宽度和高度两个参数,创建一个以原点(0,0)为左下角、指定宽高的矩形区域。这个矩形定义了:
* 宽度: 外观流内容区域的横向尺寸
* 高度: 外观流内容区域的纵向尺寸
* 坐标系统: 在这个区域内进行绘图操作,原点位于左下角
* 边界框(BBox)的作用:
* 边界框是PDF外观流的一个必需属性,它告诉PDF阅读器:
* 渲染范围: 外观流内容的有效绘制区域有多大
* 资源分配: 阅读器需要为这个区域分配多少内存和渲染资源
* 裁剪边界: 超出边界框的内容会被裁剪掉,不会显示
* 与之前位置矩形的区别:
* 之前的position矩形: 定义印章在PDF页面上的位置和大小(绝对坐标)
* 当前的bbox矩形: 定义外观流内部的绘制坐标系大小(相对坐标,从0,0开始)
* 后续使用:
* 创建这个bbox对象后,会通过appearanceStream.setBBox(bbox)将其设置给外观流,然后在这个定义的区域内绘制印章图像。
* 图像的绘制坐标会基于这个边界框的坐标系,通常是从(0,0)点开始绘制整个图像,使其填满整个边界框区域。
*/
PDRectangle bbox = new PDRectangle(width, height);
/**
* 是将之前创建的边界框矩形对象设置给外观流,定义外观流的绘制区域。
* setBBox()方法将bbox矩形(包含宽度和高度信息)绑定到appearanceStream外观流对象上。这一步是外观流配置的必要步骤。
* 为什么必须设置边界框:
* 根据PDF规范,每个外观流都必须有一个边界框(BBox)属性,它是必需字段而非可选字段。如果不设置:
* PDF文档可能不符合规范标准
* 某些PDF阅读器可能无法正确渲染印章
* 可能导致印章显示异常或完全不显示
* 设置后的效果:
* 设置边界框后,外观流就有了明确的绘制空间:
* 后续的图像绘制操作会在这个定义的区域内进行
* PDF阅读器知道需要为这个区域分配渲染资源
* 确保印章图像按照预期的尺寸显示
* 完整的配置流程:
* 在创建外观流时,通常需要完成以下配置:
* 创建外观流对象
* 设置边界框(setBBox) - 定义绘制区域大小
* 获取内容流并绘制图像
* 关闭内容流
* 将外观流设置给图章注释
* 这是构建完整外观流的关键环节之一。
*/
appearanceStream.setBBox(bbox);
/**
* 创建一个PDF资源对象,用于管理和存储外观流中使用的各种资源(如图像、字体等)。
* PDResources是PDFBox库中的一个类,代表PDF文档中的资源字典。在PDF规范中,资源字典用于集中管理页面或外观流中使用的所有外部资源。
* 资源对象的作用:
* 资源对象可以包含多种类型的资源:
* 图像资源(XObject): 嵌入的图片、照片、印章图像等
* 字体资源(Font): 文本渲染所需的字体文件
* 颜色空间(ColorSpace): 定义颜色的表示方式
* 图案(Pattern): 填充图案和渐变
* 表单(Form XObject): 可复用的图形元素
* 在印章场景中的应用:
* 对于图章注释的外观流,主要需要管理的是图像资源:
* 将印章图片转换为PDF的XObject格式
* 添加到resources资源对象中,并分配一个唯一的名称
* 后续绘制时通过这个名称引用图像
* 为什么需要资源对象:
* PDF采用分离式存储设计:
* 资源定义: 在资源字典中声明和存储
* 资源使用: 在内容流中通过名称引用
* 这种设计的优势:
* 支持资源复用,同一图像可以被多次引用
* 便于PDF阅读器预加载和管理资源
* 符合PDF规范的标准结构
*
*/
PDResources resources = new PDResources();
//将配置好的资源对象设置给外观流,使外观流能够访问和使用其中包含的图像等资源。
appearanceStream.setResources(resources);
/**
* 从文件系统中读取印章图片文件,并将其转换为PDF格式的图像对象。
* PDImageXObject.createFromFile()是一个静态工厂方法,它执行以下操作:
* 读取文件: 从image.getPath()指定的路径读取图片文件
* 格式检测: 自动识别图片格式(JPEG、PNG、TIFF等)
* 压缩处理: 根据图片格式进行适当的压缩编码
* 创建对象: 生成一个PDImageXObject对象,这是PDF中用于表示图像的标准对象类型
* 关联文档: 将图像对象添加到document文档的资源结构中
* PDImageXObject的作用:
* PDImageXObject是PDFBox中表示PDF图像的对象,它:
* 封装了图像的像素数据
* 包含图像的元信息(宽度、高度、颜色空间、分辨率等)
* 可以被嵌入到PDF页面或外观流中
* 支持多种图像格式的自动转换
* 为什么需要转换:
* 原始的图片文件(如PNG、JPG)不能直接绘制到PDF中,必须:
* 转换为PDF规范的图像格式
* 嵌入到PDF文档结构中
* 分配唯一的对象编号和引用名称
*/
PDImageXObject pdImage = PDImageXObject.createFromFile(image.getPath(), document);
//添加到之前创建的resources资源对象中
resources.add(pdImage);
// 获取图片原始尺寸
int imageWidth = pdImage.getWidth();
int imageHeight = pdImage.getHeight();
// 计算等比例缩放
float scaleX = width / imageWidth;
float scaleY = height / imageHeight;
// 选择较小的缩放比例以保持完整图片
float scale = Math.min(scaleX, scaleY);
// 计算缩放后的尺寸
float scaledWidth = imageWidth * scale;
float scaledHeight = imageHeight * scale;
// 计算居中位置
float x = (width - scaledWidth) / 2;
float y = (height - scaledHeight) / 2;
/**
* 创建一个内容流对象,用于在外观流中执行实际的绘图操作(如绘制印章图像)。
* PDPageContentStream是PDFBox中用于向PDF写入图形内容的类。这个构造函数接收两个参数
* document: PDF文档对象
* appearanceStream: 之前创建的外观流对象
* 这个构造函数的特殊之处在于,它不是向普通页面写入内容,而是向外观流写入内容。
* 内容流包含一系列PDF绘图操作符和指令,告诉PDF阅读器如何渲染视觉内容。对于印章场景,内容流会包含:
* 设置图形状态(如透明度、混合模式)
* 绘制图像的指令
* 坐标变换操作(缩放、旋转、平移)
* 其他图形绘制命令
* 为什么需要内容流:
* PDF采用"指令式"渲染模型:
* 资源层: 存储图像、字体等原始数据(已完成)
* 内容流层: 存储如何使用这些资源的指令(当前步骤)
* 渲染引擎: PDF阅读器执行内容流指令来显示最终效果
* 内容流就像是给PDF阅读器的"绘画脚本",详细说明每一步该如何绘制。
*/
try (PDPageContentStream contentStream = new PDPageContentStream(document, appearanceStream)) {
/**
* 核心代码---在外观流的内容流中绘制印章图像,指定图像的绘制位置 and 尺寸。
* drawImage()方法执行实际的图像绘制操作,接收四个参数:
* pdImage: 要绘制的图像对象(之前从文件转换得到的印章图片)
* x: 图像左下角的X坐标(绘制起始位置)
* y: 图像左下角的Y坐标(绘制起始位置)
* scaledWidth: 图像的目标宽度(会被缩放到这个宽度)
* scaledHeight: 图像的目标高度(会被缩放到这个高度)
* 绘制过程:
* 当执行这行代码时,PDFBox会:
* 将图像数据写入内容流
* 添加必要的绘图操作符
* 应用坐标变换,将图像放置到指定位置
* 设置图像的显示尺寸为指定的宽高
* 坐标系统:
* 这里的x和y坐标是相对于外观流边界框(BBox)的坐标系:
* 原点(0,0)位于边界框的左下角
* 通常印章会从(0,0)开始绘制,填满整个边界框区域
* 如果需要考虑页面旋转,可能已经在前面的步骤中进行了坐标调整
* 缩放效果:
* 如果原始图像尺寸与scaledWidth/scaledHeight不一致,图像会被自动缩放
* 保持图像的纵横比可能需要额外计算,否则可能出现拉伸变形
* 在印章场景中,通常会精确计算缩放比例以确保印章显示正确的大小
* 这行代码是整个印章添加流程中最关键的操作之一,它真正地将印章图像"画"到了PDF文档中。
* 之前所有的配置(创建外观流、设置资源、创建内容流)都是为了让这一步能够正确执行
*/
contentStream.drawImage(pdImage, x, y, scaledWidth, scaledHeight);
}
return appearanceStream;
}
/**
* @author 忧郁的Mr.Li
* @param imageFile: 实际印章图片
* @param rotation: 实际印章图片需要旋转的角度
*/
private static File rotateImageFile(File imageFile, int rotation) {
if (rotation == 0) {
return imageFile;
}
try {
// 使用ImageIO读取图片
BufferedImage originalImage = ImageIO.read(imageFile);
int originalWidth = originalImage.getWidth();
int originalHeight = originalImage.getHeight();
// 计算旋转后的尺寸
int rotatedWidth, rotatedHeight;
switch (rotation) {
case 90:
case 270:
rotatedWidth = originalHeight;
rotatedHeight = originalWidth;
break;
case 180:
rotatedWidth = originalWidth;
rotatedHeight = originalHeight;
break;
default:
return imageFile;
}
// 创建新的缓冲图片
BufferedImage rotatedImage = new BufferedImage(rotatedWidth, rotatedHeight,
originalImage.getType() == 0 ? BufferedImage.TYPE_INT_ARGB : originalImage.getType());
Graphics2D g2d = rotatedImage.createGraphics();
// 设置高质量渲染
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
// 在正确的位置绘制旋转后的图像
switch (rotation) {
case 90:
g2d.translate(rotatedWidth, 0);
g2d.rotate(Math.PI / 2);
break;
case 180:
g2d.translate(rotatedWidth, rotatedHeight);
g2d.rotate(Math.PI);
break;
case 270:
g2d.translate(0, rotatedHeight);
g2d.rotate(Math.PI * 3 / 2);
break;
}
g2d.drawImage(originalImage, 0, 0, null);
g2d.dispose();
// 保存旋转后的图片到临时文件
String tempPath = getTempPathByDayMillis();
File tempDir = new File(tempPath);
if (!tempDir.exists()) {
tempDir.mkdirs();
}
File rotatedFile = new File(tempPath, "rotated_" + System.currentTimeMillis() + "_" + imageFile.getName());
ImageIO.write(rotatedImage, "png", rotatedFile);
return rotatedFile;
} catch (Exception e) {
e.printStackTrace();
return imageFile;
}
}
/**
* @author 忧郁的Mr.Li
* 获取配置信息,后续定义一个表内容,详情参考实体类PdfSealConfig
*/
public static Map<Object, PdfSealConfig> getPfSealConfigs() throws Exception {
Map<Object, PdfSealConfig> pdfSealConfigMap = new HashMap<>();
//后续这里从数据库中读取配置表,转换成Map,做静态配置的话需要提供清除机制
//圆形公章
PdfSealConfig pdfSealConfig = new PdfSealConfig();
pdfSealConfig.setMainid(202);
pdfSealConfig.setCreatetime(new Date());
pdfSealConfig.setModifytime(new Date());
pdfSealConfig.setCreator(1);
pdfSealConfig.setModifier(1);
pdfSealConfig.setType("company");
pdfSealConfig.setShape("circular");
pdfSealConfig.setWidth(40.00);
pdfSealConfig.setHeight(40.00);
pdfSealConfig.setRemark("这是一个圆形公章");
pdfSealConfigMap.put(pdfSealConfig.getMainid(),pdfSealConfig);
//
return pdfSealConfigMap;
}
/**
* @author 忧郁的Mr.Li
* 毫米转换像素
*/
public static double convertMmToPixels(double millimeters) {
double inches = millimeters / 25.4; // 毫米转换为英寸
double pixels = inches * 72; // 英寸乘以像素密度得到像素
return pixels;
}
//印章配置实体类
@Data
public static class PdfSealConfig {
//主键
private Integer mainid;
//创建时间
private Date createtime;
//修改时间
private Date modifytime;
//创建人
private Integer creator;
//修改人
private Integer modifier;
//印章类型
private String type;
//印章形状
private String shape;
//宽度
private Double width;
//高度
private Double height;
//印章备注
private String remark;
}
}
测试结果
测试前pdf文件

测试生成的签章后的PDF文件
