使用Itext9生成PDF水印,兼容不同生成引擎的坐标系(如: Skia、OpenPDF)
问题背景
不同的PDF生成引擎可能使用不同的坐标系约定:
- 标准PDF坐标系:原点在页面左下角,Y轴向上
- Skia引擎坐标系:可能存在Y轴翻转或其他矩阵变换
- OpenPDF引擎:在某些情况下可能产生镜像或其他异常矩阵变换
- 其他PDF库:各种第三方PDF库可能产生不同的矩阵配置
这种差异会导致在添加水印时出现文字镜像、位置错误或显示异常等问题。需要进行矩阵兼容性检测和修正。
核心检测逻辑
1. 页面翻转检测
java
private static boolean pageFlip(PdfPage page)
该函数通过分析PDF页面的内容流,检测是否存在需要镜像修正的变换矩阵。
检测流程:
- 获取页面内容流
- 使用
PdfCanvasProcessor处理内容 - 通过
EnhancedMirrorListener监听各类渲染事件 - 分析变换矩阵判断是否需要修正
2. 变换矩阵分析
java
private static boolean needsMirrorCorrection(Matrix m)
该方法是最核心的矩阵兼容性检测逻辑,通过分析PDF变换矩阵的数学特性,判断是否需要进行镜像修正。它能兼容多种PDF引擎(包括Skia、OpenPDF等)产生的不同矩阵配置。
变换矩阵结构
PDF使用3×3齐次变换矩阵,其中前6个元素(a, b, c, d, e, f)分别对应:
[ a b 0 ] [ a b 0 ]
[ c d 0 ] = [ c d 0 ]
[ e f 1 ] [ e f 1 ]
- a (0,0):X轴缩放因子
- b (0,1):Y轴倾斜因子
- c (1,0):X轴倾斜因子
- d (1,1):Y轴缩放因子
- e (2,0):X轴平移量
- f (2,1):Y轴平移量
精度控制
java
private static final float EPS = 1e-3f;
使用EPS阈值处理浮点数精度问题,避免因数值误差导致的误判。
详细检测逻辑
情况1:标准Y轴翻转检测
java
boolean skiaFlip = Math.abs(b) < EPS && Math.abs(c) < EPS &&
d < -EPS && Math.abs(a + d) < Math.max(Math.abs(a), Math.abs(d)) * 0.1f;
数学原理:
Math.abs(b) < EPS && Math.abs(c) < EPS:确认无倾斜变换,仅包含缩放和平移d < -EPS:Y轴缩放为负值,表示Y轴翻转Math.abs(a + d) < Math.max(Math.abs(a), Math.abs(d)) * 0.1f:X轴缩放与Y轴缩放绝对值近似相等
适用场景:
- Skia引擎的Y轴翻转
- OpenPDF某些版本的垂直镜像
- 其他具有Y轴翻转特性的PDF库
矩阵示例:
[ 1.0 0.0 0 ]
[ 0.0 -1.0 0 ]
[ 0.0 0.0 1 ]
情况2:水平镜像翻转检测
java
boolean horizontalFlip = Math.abs(b) < EPS && Math.abs(c) < EPS &&
a < -EPS && Math.abs(a - d) < Math.max(Math.abs(a), Math.abs(d)) * 0.1f;
数学原理:
- 无倾斜变换约束同情况1
a < -EPS:X轴缩放为负值,表示X轴翻转Math.abs(a - d) < Math.max(Math.abs(a), Math.abs(d)) * 0.1f:X轴与Y轴缩放因子绝对值近似相等
适用场景:
- OpenPDF的水平镜像问题
- 其他产生水平翻转的PDF引擎
- 某些坐标系转换导致的水平镜像
矩阵示例:
[ -1.0 0.0 0 ]
[ 0.0 1.0 0 ]
[ 0.0 0.0 1 ]
情况3:问题矩阵通用检测
java
boolean problematicMatrix = Math.abs(a + d) < EPS ||
(a < -EPS && d > EPS) ||
(Math.abs(a - d) > 2.0f && Math.abs(b) < EPS && Math.abs(c) < EPS);
子情况3.1:奇异矩阵检测
java
Math.abs(a + d) < EPS
数学原理: a + d ≈ 0 意味着矩阵行列式可能接近零,属于奇异矩阵,会导致渲染异常。
子情况3.2:不对称缩放检测
java
(a < -EPS && d > EPS)
数学原理: X轴负缩放但Y轴正缩放,这种不对称配置通常会导致文字显示异常。
子情况3.3:过度缩放检测
java
(Math.abs(a - d) > 2.0f && Math.abs(b) < EPS && Math.abs(c) < EPS)
数学原理: 在无倾斜变换的情况下,X轴和Y轴缩放差异过大(超过2倍),可能导致渲染变形。
适用场景:
- 各种PDF引擎的异常矩阵输出
- PDF文档损坏或格式错误
- 复杂变换操作导致的矩阵异常
- 第三方PDF库的兼容性问题
容错机制
该方法通过多层次的检测逻辑实现高容错性:
- 渐进式检测:从特定类型到通用问题,逐步扩大检测范围
- 相对比较:使用相对比例而非绝对值,适应不同尺寸的页面
- 阈值控制 :通过
EPS和比例系数控制误判率 - 逻辑或运算:任一条件满足即触发修正,确保异常不遗漏
兼容性覆盖
该检测方法能够识别和修正以下情况:
| PDF引擎/场景 | 检测情况 | 修正策略 |
|---|---|---|
| Skia引擎Y轴翻转 | 情况1 | 坐标系修正 |
| OpenPDF水平镜像 | 情况2 | 镜像翻转修正 |
| 矩阵奇异或异常 | 情况3.1 | 通用修正处理 |
| 不对称缩放 | 情况3.2 | 通用修正处理 |
| 过度缩放变形 | 情况3.3 | 通用修正处理 |
| 其他未知矩阵 | 任意情况 | 通用修正处理 |
性能考虑
- 计算复杂度:O(1) 常数时间复杂度
- 内存使用:仅使用几个局部变量
- 适用性:适用于批量处理大量PDF文件
- 准确性:通过多重条件确保高准确率
3. 事件监听机制
EnhancedMirrorListener类监听三类渲染事件:
| 事件类型 | 数据源 | 检测矩阵 | 说明 |
|---|---|---|---|
RENDER_PATH |
PathRenderInfo |
getCtm() |
路径绘制的当前变换矩阵 |
RENDER_IMAGE |
ImageRenderInfo |
getImageCtm() |
图像绘制的变换矩阵 |
RENDER_TEXT |
TextRenderInfo |
getTextMatrix() |
文字绘制的变换矩阵 |
兼容处理策略
1. Skia格式处理
java
public static void addWatermark2SKiaFormat(PdfDocument pdfDoc, Paragraph watermark, float xSpacing, float ySpacing)
处理特点:
- 应用坐标系修正变换
- 使用
(100, 100)的平移补偿 - 调整水印起始位置确保完整覆盖
- 保持原始水印属性(字体、大小、颜色、透明度)
2. 坐标系修正变换
java
private static AffineTransform createCoordinateTransform() {
AffineTransform at = new AffineTransform();
at.translate(100, 100);
return at;
}
设计考虑:
- 仅进行平移变换,不影响缩放
- 补偿页面坐标系偏移
- 避免对水印文字大小的二次缩放
3. 水印位置计算
X轴起始位置:
java
private static float calculateWatermarkXStart(float xSpacing) {
return -xSpacing - 100; // 补偿平移影响
}
Y轴起始位置:
java
private static float calculateWatermarkYStart(float ySpacing, float pageHeight) {
return pageHeight + ySpacing - 100; // 补偿平移影响
}
4. 普通格式处理
对于needsMirrorCorrection方法返回false的标准PDF,使用简化处理逻辑:
- 直接在原始坐标系中绘制水印
- 使用固定的起始位置偏移
- 保持45度旋转角度
- 无需应用坐标变换修正
关键参数
| 参数名 | 值 | 说明 |
|---|---|---|
EPS |
1e-3f |
浮点比较阈值,防止数值误差 |
xSpacing |
125f |
水印横向间距 |
ySpacing |
150f |
水印纵向间距 |
| 平移量 | (100, 100) |
坐标系修正补偿量 |
| 比例系数 | 0.1f |
用于检测近似相等的缩放因子 |
| 缩放差异阈值 | 2.0f |
检测过度缩放的差异阈值 |
needsMirrorCorrection方法核心流程
java
private static boolean needsMirrorCorrection(Matrix m) {
if (m == null) return false;
// 提取矩阵关键元素
float a = m.get(0); // X轴缩放
float b = m.get(1); // Y轴倾斜
float c = m.get(3); // X轴倾斜
float d = m.get(4); // Y轴缩放
// 三种检测情况的逻辑或运算
boolean skiaFlip = /* 情况1逻辑 */;
boolean horizontalFlip = /* 情况2逻辑 */;
boolean problematicMatrix = /* 情况3逻辑 */;
return skiaFlip || horizontalFlip || problematicMatrix;
}
使用流程
- 检测阶段 :调用
pageFlip()检测首页是否需要修正 - 矩阵分析 :
pageFlip()内部调用needsMirrorCorrection()对每个检测到的矩阵进行分析 - 分支处理 :
- 需要修正:调用
addWatermark2SKiaFormat() - 无需修正:调用
addWatermark2NormalFormat()
- 需要修正:调用
- 统一输出:返回添加水印后的PDF流
矩阵检测的实际应用
检测触发条件
当PDF页面中包含以下任一情况时,needsMirrorCorrection将返回true:
- Skia引擎生成的PDF:常见的Y轴翻转矩阵
- OpenPDF特定版本:可能产生水平镜像
- 复杂变换操作:多层嵌套的矩阵变换
- PDF文档异常:损坏或不规范的矩阵定义
修正策略选择
根据needsMirrorCorrection的返回值选择不同的水印添加策略:
- 返回true :使用
addWatermark2SKiaFormat()进行坐标系修正 - 返回false :使用
addWatermark2NormalFormat()标准处理
调试支持
工具类提供了详细的调试输出,特别针对needsMirrorCorrection方法:
java
// 调试:打印变换矩阵信息
private static void logTransformMatrix(AffineTransform at, String description)
// 记录检测结果
if (listener.needsCorrection) {
System.out.printf("检测到需要镜像修正的页面,扫描到 %d 个矩阵%n", listener.matrixCount);
}
生产环境可通过注释相关调试代码关闭输出。
注意事项
- 性能考虑:仅检测首页矩阵作为判断依据,避免全文档扫描的性能开销
- 容错机制 :使用
EPS阈值和相对比例处理浮点数精度问题 - 向后兼容:确保对标准PDF的正常处理不受影响
- 内存管理 :及时关闭
PdfDocument和相关流对象 - 矩阵范围:该方法专注于检测缩放和倾斜相关的变换,不涉及平移量的分析
完整代码
java
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.AffineTransform;
import com.itextpdf.kernel.geom.Matrix;
import com.itextpdf.kernel.pdf.*;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.kernel.colors.DeviceRgb;
import com.itextpdf.kernel.pdf.canvas.parser.EventType;
import com.itextpdf.kernel.pdf.canvas.parser.PdfCanvasProcessor;
import com.itextpdf.kernel.pdf.canvas.parser.data.IEventData;
import com.itextpdf.kernel.pdf.canvas.parser.data.ImageRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.data.PathRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.data.TextRenderInfo;
import com.itextpdf.kernel.pdf.canvas.parser.listener.IEventListener;
import com.itextpdf.layout.Canvas;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.properties.TextAlignment;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.layout.properties.VerticalAlignment;
import java.io.*;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/**
* @ClassName: FileWatermarkUtils
* @Description: 文件水印工具类
* @Author: Maxon Phong
* @Date: 2025/10/10 9:50
**/
public class FileWatermarkUtils {
/**
* PDF加水印
*
* @param file 文件
* @param watermarkText 水印文本
* @return 添加水印后的PDF文件
*/
public static InputStream addWatermark2PDF(File file, String watermarkText) {
try {
return addWatermark2PDF(new FileInputStream(file), watermarkText);
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
}
/**
* PDF加水印
*
* @param inputStream 文件流
* @param watermarkText 水印文本
* @return 添加水印后的PDF文件流
*/
public static InputStream addWatermark2PDF(InputStream inputStream, String watermarkText) {
if (null == inputStream) {
throw new RuntimeException("输入文件不能为空");
}
if (null == watermarkText) {
return inputStream;
// watermarkText = StringUtils.EMPTY;
}
try {
// ------------------- 1. 基础文档准备 -------------------
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
PdfReader reader = new PdfReader(inputStream);
PdfWriter writer = new PdfWriter(outputStream);
PdfDocument pdfDoc = new PdfDocument(reader, writer);
// 中文字体(STSong-Light)支持中文水印
PdfFont font = PdfFontFactory.createFont("STSong-Light", "UniGB-UCS2-H");
// ------------------- 2. 水印样式(Paragraph) -------------------
Paragraph paragraph = new Paragraph(watermarkText)
.setFont(font)
.setFontSize(15) // 这里写你想要的字号
.setFontColor(new DeviceRgb(200, 200, 200))
.setOpacity(0.5f); // 透明度
// 水印平铺的间距(可自行调节)
final float xSpacing = 125f;
final float ySpacing = 150f;
// 判断文件格式 - 使用增强的镜像检测
// 首页判断
if (pageFlip(pdfDoc.getPage(1))) {
addWatermark2SKiaFormat(pdfDoc, paragraph, xSpacing, ySpacing);
} else {
addWatermark2NormalFormat(pdfDoc, paragraph, xSpacing, ySpacing);
}
pdfDoc.close();
// 返回添加水印后的PDF流
return new java.io.ByteArrayInputStream(outputStream.toByteArray());
} catch (Exception e) {
throw new RuntimeException("添加水印失败: " + e.getMessage(), e);
}
}
/**
* PDF加水印 -- Skia 格式(增强版,自动修正镜像问题)
*
* @param pdfDoc pdf文档
* @param watermark 水印
* @param xSpacing 水印横向间距
* @param ySpacing 水印纵向间距
*/
public static void addWatermark2SKiaFormat(PdfDocument pdfDoc, Paragraph watermark, float xSpacing, float ySpacing) {
// ------------------- 3. 遍历每页并绘制 -------------------
int numberOfPages = pdfDoc.getNumberOfPages();
for (int i = 1; i <= numberOfPages; i++) {
PdfPage page = pdfDoc.getPage(i);
Rectangle pageSize = page.getPageSize();
// ① 在页面最上层(相当于 getOverContent)创建 PdfCanvas
PdfCanvas pdfCanvas = new PdfCanvas(page.newContentStreamAfter(),
page.getResources(), pdfDoc);
// ② 保存当前图形状态,后面恢复,防止影响其它页面
pdfCanvas.saveState();
// ④ 智能坐标系修正 - 分两步处理,避免影响字体大小
// 步骤1:只对坐标变换进行修正,不对缩放操作
AffineTransform coordinateTransform = createCoordinateTransform();
// 调试:打印变换矩阵信息
// logTransformMatrix(coordinateTransform, "Skia坐标系修正");
pdfCanvas.concatMatrix(coordinateTransform);
// ⑤ 使用高层 Canvas 绘制文字
Canvas waterMarkCanvas = new Canvas(pdfCanvas, pageSize);
// 计算起始坐标,确保覆盖整个页面
float xStart = calculateWatermarkXStart(xSpacing);
float yStart = calculateWatermarkYStart(ySpacing, pageSize.getHeight());
// 绘制水印网格 - 确保覆盖整个页面(考虑100像素的平移)
// Y轴循环范围:从页面顶部+100 到 页面底部+100
float yEnd = -100; // 考虑平移的结束位置
for (float y = yStart; y > yEnd; y -= ySpacing) {
// X轴循环范围:从-100-间距 到 页面宽度+100+间距
float xEnd = pageSize.getWidth() + 100 + xSpacing;
for (float x = xStart; x < xEnd; x += xSpacing) {
waterMarkCanvas.showTextAligned(watermark,
x, y, i,
TextAlignment.CENTER,
VerticalAlignment.MIDDLE,
(float) Math.toRadians(45)); // 45° 旋转
}
}
waterMarkCanvas.close(); // 结束 Canvas 使用
pdfCanvas.restoreState(); // 恢复图形状态,防止影响后续页面
}
}
/**
* 创建坐标系修正变换矩阵(只处理坐标,不影响缩放)
*/
private static AffineTransform createCoordinateTransform() {
AffineTransform at = new AffineTransform();
at.translate(100, 100);
return at;
}
/**
* 计算水印X轴起始位置
*/
private static float calculateWatermarkXStart(float xSpacing) {
// 从页面左侧开始,确保完整覆盖,考虑(100,100)平移
return -xSpacing - 100; // 补偿平移影响
}
/**
* 计算水印Y轴起始位置
*/
private static float calculateWatermarkYStart(float ySpacing, float pageHeight) {
// 考虑我们有(100, 100)的平移,调整起始位置
// 从页面顶部开始,减去平移量
return pageHeight + ySpacing - 100; // 补偿平移影响
}
/**
* 记录水印调试信息
*/
private static void logWatermarkInfo(int pageNum, Rectangle pageSize, float userUnit, float xStart, float yStart) {
// 生产环境可以注释掉这些调试信息
System.out.printf(
"页面 %d 水印信息: 尺寸=%.1fx%.1f, UserUnit=%.2f, 起始=(%.1f,%.1f)%n",
pageNum, pageSize.getWidth(), pageSize.getHeight(), userUnit, xStart, yStart
);
}
/**
* 打印变换矩阵信息(调试用)
*/
private static void logTransformMatrix(AffineTransform at, String description) {
System.out.printf(
"%s - 变换矩阵: [%f, %f, %f, %f, %f, %f]%n",
description,
at.getScaleX(), at.getShearY(), at.getShearX(), at.getScaleY(),
at.getTranslateX(), at.getTranslateY()
);
}
/**
* PDF加水印 -- 普通格式
*
* @param pdfDoc pdf文档
* @param watermark 水印文本
* @param xSpacing 水印横向间距
* @param ySpacing 水印纵向间距
*/
public static void addWatermark2NormalFormat(PdfDocument pdfDoc, Paragraph watermark, float xSpacing, float ySpacing) {
// 遍历每一页添加水印
int numberOfPages = pdfDoc.getNumberOfPages();
for (int i = 1; i <= numberOfPages; i++) {
PdfPage page = pdfDoc.getPage(i);
Rectangle pageSize = page.getPageSize();
PdfCanvas pdfCanvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), pdfDoc);
// 计算水印的位置,实现平铺效果
float xStart = -75; // 起始X位置
float yStart = pageSize.getHeight() - 130; // 起始Y位置
// 在整个页面上循环添加水印
Canvas waterMarkCanvas = new Canvas(pdfCanvas, pageSize);
for (float y = yStart; y > 0; y -= ySpacing) {
for (float x = xStart; x < pageSize.getWidth() + 300; x += xSpacing) {
waterMarkCanvas.showTextAligned(watermark, x, y, i,
TextAlignment.CENTER,
VerticalAlignment.MIDDLE,
(float) Math.toRadians(45)
);
}
}
waterMarkCanvas.close();
}
}
/**
* 判定阈值,防止浮点误差
*/
private static final float EPS = 1e-3f; // 放宽阈值,提高容错性
/**
* 判断是否需要镜像修正(包括Skia翻转和其它坐标系问题)
*/
private static boolean needsMirrorCorrection(Matrix m) {
if (m == null) return false;
// 3×3 矩阵的前 6 项对应 a、b、c、d、e、f(行主序)
float a = m.get(0); // 第 1 行第 1 列 (X缩放)
float b = m.get(1); // 第 1 行第 2 列 (Y倾斜)
float c = m.get(3); // 第 2 行第 1 列 (X倾斜)
float d = m.get(4); // 第 2 行第 2 列 (Y缩放)
// 情况1:标准Skia Y轴翻转(a > 0, d < 0, 且 |a| ≈ |d|)
boolean skiaFlip = Math.abs(b) < EPS && Math.abs(c) < EPS &&
d < -EPS && Math.abs(a + d) < Math.max(Math.abs(a), Math.abs(d)) * 0.1f;
// 情况2:水平镜像翻转(a < 0, d > 0, 且 |a| ≈ |d|)
boolean horizontalFlip = Math.abs(b) < EPS && Math.abs(c) < EPS &&
a < -EPS && Math.abs(a - d) < Math.max(Math.abs(a), Math.abs(d)) * 0.1f;
// 情况3:检测其它可能导致文字镜像的矩阵问题
boolean problematicMatrix = Math.abs(a + d) < EPS ||
(a < -EPS && d > EPS) ||
(Math.abs(a - d) > 2.0f && Math.abs(b) < EPS && Math.abs(c) < EPS);
return skiaFlip || horizontalFlip || problematicMatrix;
}
/**
* 解析单页内容,返回是否检测到需要镜像修正的格式(增强版)
*/
private static boolean pageFlip(PdfPage page) {
// 读取页面全部内容流(iText 会自动合并多个流)
byte[] contentBytes = page.getContentBytes();
if (contentBytes == null) {
return false; // 空页
}
// 增强的镜像检测 Listener
class EnhancedMirrorListener implements IEventListener {
boolean needsCorrection = false;
int matrixCount = 0;
@Override
public void eventOccurred(IEventData data, EventType type) {
// 检测所有可能包含变换矩阵的事件
if (type == EventType.RENDER_PATH) {
PathRenderInfo pri = (PathRenderInfo) data;
if (needsMirrorCorrection(pri.getCtm())) {
needsCorrection = true;
}
matrixCount++;
} else if (type == EventType.RENDER_IMAGE) {
ImageRenderInfo iri = (ImageRenderInfo) data;
if (needsMirrorCorrection(iri.getImageCtm())) {
needsCorrection = true;
}
matrixCount++;
} else if (type == EventType.RENDER_TEXT) {
TextRenderInfo tri = (TextRenderInfo) data;
if (needsMirrorCorrection(tri.getTextMatrix())) {
needsCorrection = true;
}
matrixCount++;
}
}
@Override
public Set<EventType> getSupportedEvents() {
return new HashSet<>(Arrays.asList(
EventType.RENDER_PATH,
EventType.RENDER_IMAGE,
EventType.RENDER_TEXT));
}
}
EnhancedMirrorListener listener = new EnhancedMirrorListener();
PdfCanvasProcessor processor = new PdfCanvasProcessor(listener);
processor.processContent(contentBytes, page.getResources());
// 记录检测结果(调试用)
if (listener.needsCorrection) {
System.out.printf("检测到需要镜像修正的页面,扫描到 %d 个矩阵%n", listener.matrixCount);
}
return listener.needsCorrection;
}
}