1. SpringBoot实现Excel导出带水印的完整方案
最近在开发一个数据导出功能时,遇到了需要给导出的Excel文件添加水印的需求。经过一番研究和实践,我总结出了两种不同的实现方案,各有优缺点。下面我会详细介绍这两种方案的具体实现方法、适用场景以及实际使用中的注意事项。
水印在文档保护中扮演着重要角色,它能够:
- 标识文档来源和归属
- 防止敏感数据被滥用
- 追踪文档泄露渠道
- 增强文档的专业性和正式感
在Java生态中,Apache POI是处理Office文档的事实标准,我们也将基于它来实现Excel水印功能。
2. 环境准备与基础配置
2.1 Maven依赖配置
首先需要在项目中添加POI相关依赖。这里需要注意POI 4.x和5.x版本API有差异,建议使用5.x版本以获得更好的性能和稳定性:
xml复制<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.4.0</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.4.0</version>
</dependency>
重要提示:xls格式(HSSF)和csv文件无法实现水印效果,必须使用xlsx格式(XSSF)
2.2 字体选择与处理
微软雅黑等商业字体存在版权风险,在生产环境中建议使用开源字体。我推荐使用更纱黑体(Sarasa Gothic),这是一款优秀的开源中文字体:
- 从GitHub下载字体:https://github.com/be5invis/Sarasa-Gothic
- 将字体文件(如SarasaGothicSC-Regular.ttf)放入项目的resources/fonts目录
- 部署时确保字体文件被打包到最终jar中
字体加载代码示例:
java复制private static Font loadFontFromResource(String fontPath, int style, float size) {
try (InputStream is = ExcelWatermarkUtil.class.getClassLoader()
.getResourceAsStream(fontPath)) {
if (is == null) {
throw new RuntimeException("字体文件未找到: " + fontPath);
}
return Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(style, size);
} catch (Exception e) {
throw new RuntimeException("加载字体失败", e);
}
}
3. 方案一:不可编辑的强水印
3.1 实现原理
这种方案通过将水印作为背景图片平铺在整个工作表上,并锁定工作表保护,实现:
- 水印无法被选中或删除
- 整个工作表内容不可编辑
- 水印在打印和导出PDF时仍然可见
3.2 核心代码实现
java复制public class ExcelWatermarkUtilV2 {
// 水印配置常量
private static final int WATERMARK_IMAGE_WIDTH = 400;
private static final int WATERMARK_IMAGE_HEIGHT = 120;
private static final int FONT_SIZE = 20;
private static final int ROTATION_ANGLE = -20;
// 水印布局配置
private static final int WATERMARK_COLS_SPAN = 6;
private static final int WATERMARK_ROWS_SPAN = 12;
private static final int COL_SPACING = 6;
private static final int ROW_SPACING = 12;
public static void setWaterMark(Workbook workbook, String watermark, String password) {
try {
// 生成水印图片
BufferedImage image = createWatermarkImage(watermark);
ByteArrayOutputStream os = new ByteArrayOutputStream();
ImageIO.write(image, "png", os);
// 为所有工作表设置背景图片
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
Sheet sheet = workbook.getSheetAt(i);
int pictureIdx = workbook.addPicture(os.toByteArray(),
Workbook.PICTURE_TYPE_PNG);
Drawing<?> drawing = sheet.createDrawingPatriarch();
// 平铺水印
for (int col = 0; col < maxCol; col += COL_SPACING) {
for (int row = 0; row < maxRow; row += ROW_SPACING) {
ClientAnchor anchor = drawing.createAnchor(0, 0, 0, 0,
col, row, col + WATERMARK_COLS_SPAN,
row + WATERMARK_ROWS_SPAN);
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
drawing.createPicture(anchor, pictureIdx);
}
}
protectSheet(sheet, password);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
3.3 水印图片生成优化
水印图片生成需要考虑文字长度、旋转角度等因素:
java复制private static BufferedImage createWatermarkImage(String text) {
Font font = loadFontFromResource("fonts/SarasaGothicSC-Regular.ttf",
Font.BOLD, FONT_SIZE);
// 计算文字实际尺寸
BufferedImage tempImage = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
Graphics2D tempG2d = tempImage.createGraphics();
tempG2d.setFont(font);
FontMetrics metrics = tempG2d.getFontMetrics();
int textWidth = metrics.stringWidth(text);
int textHeight = metrics.getHeight();
// 计算旋转后需要的空间
double radians = Math.toRadians(Math.abs(ROTATION_ANGLE));
int rotatedWidth = (int) (textWidth * Math.cos(radians) +
textHeight * Math.sin(radians));
int rotatedHeight = (int) (textWidth * Math.sin(radians) +
textHeight * Math.cos(radians));
// 创建最终图片
BufferedImage image = new BufferedImage(
Math.max(WATERMARK_IMAGE_WIDTH, rotatedWidth + 30),
Math.max(WATERMARK_IMAGE_HEIGHT, rotatedHeight + 15),
BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setComposite(AlphaComposite.SrcOver);
g2d.setColor(new Color(200, 200, 200, 120));
g2d.setFont(font);
g2d.rotate(Math.toRadians(ROTATION_ANGLE),
(double) width / 2, (double) height / 2);
// 绘制文字
int x = (width - metrics.stringWidth(text)) / 2;
int y = height / 2 + metrics.getAscent() / 2 - metrics.getDescent() / 2;
g2d.drawString(text, x, y);
g2d.dispose();
return image;
}
3.4 工作表保护设置
java复制private static void protectSheet(Sheet sheet, String password) {
sheet.protectSheet(password != null ? password : "");
if (sheet instanceof XSSFSheet) {
XSSFSheet xssfSheet = (XSSFSheet) sheet;
// 禁止用户进行的操作
xssfSheet.lockDeleteColumns(false);
xssfSheet.lockDeleteRows(false);
xssfSheet.lockFormatCells(false);
// 锁定绘图对象(包括水印图片)
xssfSheet.lockObjects(true);
// 允许选择单元格
xssfSheet.lockSelectLockedCells(true);
xssfSheet.lockSelectUnlockedCells(true);
}
}
4. 方案二:可编辑的弱水印
4.1 实现原理
这种方案将水印作为工作表的一部分添加:
- 单元格内容可以编辑
- 但全选复制到新文件时水印会丢失
- 适用于需要修改数据但又要标识来源的场景
4.2 核心代码实现
java复制public class ExcelWatermarkExport {
public static void exportWithWatermark() throws Exception {
XSSFWorkbook workbook = new XSSFWorkbook();
XSSFSheet sheet = workbook.createSheet("Sheet1");
// 写入测试数据
for (int i = 0; i < 20; i++) {
Row row = sheet.createRow(i);
for (int j = 0; j < 10; j++) {
Cell cell = row.createCell(j);
cell.setCellValue("测试数据");
}
}
// 生成并添加水印图片
byte[] imageBytes = createWatermarkImage("admin|2026-02-07|测试部门");
int pictureIdx = workbook.addPicture(imageBytes, Workbook.PICTURE_TYPE_PNG);
// 建立图片与工作表的关联
PackagePart sheetPart = sheet.getPackagePart();
PackagePart imagePart = workbook.getAllPictures().get(pictureIdx)
.getPackagePart();
PackageRelationship relationship = sheetPart.addRelationship(
imagePart.getPartName(), TargetMode.INTERNAL,
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image");
// 写入底层XML
CTWorksheet ctWorksheet = sheet.getCTWorksheet();
ctWorksheet.addNewPicture().setId(relationship.getId());
// 设置单元格可编辑
CellStyle unlockedStyle = workbook.createCellStyle();
unlockedStyle.setLocked(false);
for (Row row : sheet) {
for (Cell cell : row) {
cell.setCellStyle(unlockedStyle);
}
}
// 启用保护(密码可选)
sheet.protectSheet("123456");
// 输出文件
try (FileOutputStream fos = new FileOutputStream("watermark.xlsx")) {
workbook.write(fos);
}
workbook.close();
}
}
4.3 水印图片生成
java复制private static byte[] createWatermarkImage(String text) throws Exception {
int width = 1000;
int height = 600;
BufferedImage image = new BufferedImage(width, height,
BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.15f));
g2d.setColor(Color.GRAY);
g2d.setFont(new Font("Sarasa Gothic SC", Font.BOLD, 100));
g2d.rotate(Math.toRadians(-30), width / 2, height / 2);
FontMetrics fm = g2d.getFontMetrics();
int x = (width - fm.stringWidth(text)) / 2;
int y = height / 2;
g2d.drawString(text, x, y);
g2d.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "png", baos);
return baos.toByteArray();
}
5. 两种方案对比与选择建议
5.1 功能对比
| 特性 | 方案一(强水印) | 方案二(弱水印) |
|---|---|---|
| 水印不可删除 | ✓ | ✗ |
| 内容可编辑 | ✗ | ✓ |
| 复制后保留水印 | ✓ | ✗ |
| 打印/PDF保留水印 | ✓ | ✓ |
| 实现复杂度 | 较高 | 较低 |
| 适用场景 | 最终版文档分发 | 内部协作编辑 |
5.2 选择建议
- 需要绝对防篡改:选择方案一,它提供了最强的保护,防止用户修改内容和删除水印
- 需要编辑内容:选择方案二,允许用户修改数据但保留水印标识
- 防泄密要求高:方案一更优,因为即使复制内容到新文件,水印仍然存在
- 用户体验优先:方案二更好,不会限制用户的编辑操作
6. 常见问题与解决方案
6.1 水印显示不全
问题现象:水印文字被截断或只显示部分
解决方案:
- 增大水印图片的尺寸
- 调整水印文字字体大小
- 检查旋转角度是否导致文字超出图片边界
- 增加水印图片的边距
6.2 水印密度不合适
问题现象:水印太密集影响阅读或太稀疏起不到标识作用
调整方法:
java复制// 调整这些参数控制水印密度
private static final int COL_SPACING = 6; // 增大值减少列方向密度
private static final int ROW_SPACING = 12; // 增大值减少行方向密度
6.3 字体加载失败
问题现象:抛出字体未找到或加载失败异常
排查步骤:
- 确认字体文件路径正确
- 检查字体文件是否被打包到最终jar/war
- 验证字体文件权限
- 考虑使用系统备用字体
6.4 性能优化建议
当处理大数据量导出时:
- 缓存水印图片,避免每次导出都重新生成
- 考虑使用线程池处理多个工作表的加水印操作
- 对于固定水印文本,可以预生成图片字节数组
6.5 服务器部署问题
常见问题:
- 字体在开发环境有效,但部署后失效
- 水印功能在Windows正常但在Linux异常
解决方案:
- 确保字体文件随应用一起部署
- 在Linux服务器安装所需字体
- 检查文件路径大小写敏感性(Linux区分大小写)
- 验证文件权限
7. 高级应用与扩展
7.1 动态水印内容
可以根据导出上下文添加动态信息:
java复制String format = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
String waterMarkText = "操作员:" + currentUser + "|导出时间:" + format;
7.2 多语言支持
根据不同地区用户显示不同语言的水印:
java复制String waterMarkText;
if (Locale.CHINA.equals(userLocale)) {
waterMarkText = "机密文件-仅限内部使用";
} else {
waterMarkText = "Confidential - Internal Use Only";
}
7.3 水印元数据追踪
在水印中嵌入隐形元数据,便于追踪:
java复制String meta = Base64.getEncoder()
.encodeToString((userId+"|"+exportTime).getBytes());
String waterMarkText = "公司内部文件 " + meta;
7.4 自定义水印样式
通过参数支持多种样式:
java复制public enum WatermarkStyle {
DIAGONAL, // 对角线
HORIZONTAL, // 水平
VERTICAL, // 垂直
GRID // 网格
}
public static void setWaterMark(..., WatermarkStyle style) {
switch(style) {
case DIAGONAL:
// 设置对角线水印
break;
// 其他样式处理
}
}
在实际项目中,我推荐将水印功能封装成独立的服务模块,通过配置文件控制水印的各种参数,这样可以在不同业务场景中灵活复用。同时,要注意记录水印添加日志,便于后续审计追踪。