在日常开发中,我遇到一个需求:在数据流转到某个节点时,需要把相应的文件都添加上水印。文件类型可能是图片、PDF、Word、Excel,每种文件的写法都不同,而且 Word 还分为 .doc 和 .docx,Excel 分为 .xls 和 .xlsx。虽然过程曲折,最终还是完成了一套通用方法。
本文将从主方法设计 、辅助工具方法 、依赖说明 三个部分完整介绍这套方案。
1. addWatermark 主方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 public static InputStream addWatermark (InputStream fileInputStreamFile, InputStream watermarkInputStream, String fileType, int x, int y, float opacity) throws Exception { ByteArrayOutputStream outputStream = new ByteArrayOutputStream (); BufferedImage originalImage; BufferedImage watermarkImage = null ; byte [] fileBytes = toByteArray(fileInputStreamFile); try { watermarkImage = ImageIO.read(watermarkInputStream); } catch (IOException e) { throw new IOException ("未找到需添加的水印图片" , e); } if (isImage(new ByteArrayInputStream (fileBytes))) { InputStream cachedStream = new ByteArrayInputStream (fileBytes); originalImage = ImageIO.read(cachedStream); if (ObjectUtil.isEmpty(originalImage)) { throw new IllegalArgumentException ("原始图像加载失败,请检查文件路径或格式" ); } BufferedImage watermarkedImage = addWatermarkToImage(originalImage, watermarkImage, x, y, opacity); ImageIO.write(watermarkedImage, "png" , outputStream); return new ByteArrayInputStream (outputStream.toByteArray()); } else if (isPDF(new ByteArrayInputStream (fileBytes))) { PDDocument document = PDDocument.load(fileBytes); PDImageXObject pdImage = LosslessFactory.createFromImage(document, watermarkImage); for (PDPage page : document.getPages()) { try (PDPageContentStream contentStream = new PDPageContentStream ( document, page, PDPageContentStream.AppendMode.APPEND, true , true )) { PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState (); graphicsState.setNonStrokingAlphaConstant(opacity); graphicsState.setAlphaSourceFlag(true ); contentStream.setGraphicsStateParameters(graphicsState); contentStream.drawImage(pdImage, x, y); } } document.save(outputStream); document.close(); return new ByteArrayInputStream (outputStream.toByteArray()); } else if (isExcel(new ByteArrayInputStream (fileBytes), fileType)) { InputStream cachedStream = new ByteArrayInputStream (fileBytes); boolean isOld = isOldWordFormat(fileBytes); BufferedImage transparentWatermark = createWatermarkWithOpacity(watermarkImage, opacity); if (isOld) { ZipSecureFile.setMinInflateRatio(0.001 ); try { HSSFWorkbook workbook = new HSSFWorkbook (cachedStream); addWatermarkToExcel(workbook, transparentWatermark, x, y, opacity); workbook.write(outputStream); workbook.close(); } finally { ZipSecureFile.setMinInflateRatio(0.001 ); } } else { ZipSecureFile.setMinInflateRatio(0.001 ); try { XSSFWorkbook workbook = new XSSFWorkbook (cachedStream); addWatermarkToExcel(workbook, transparentWatermark, x, y, opacity); workbook.write(outputStream); workbook.close(); } finally { ZipSecureFile.setMinInflateRatio(0.001 ); } } return new ByteArrayInputStream (outputStream.toByteArray()); } else if (isWord(new ByteArrayInputStream (fileBytes), fileType)) { InputStream cachedStream = new ByteArrayInputStream (fileBytes); boolean isOldWord = isOldWordFormat(fileBytes); if (isOldWord) { File tmpDoc = File.createTempFile("tmpWord" , ".doc" ); Files.copy(cachedStream, tmpDoc.toPath(), StandardCopyOption.REPLACE_EXISTING); File converted = convertDocToDocx(tmpDoc); ZipSecureFile.setMinInflateRatio(0.001 ); try (InputStream convertedStream = new FileInputStream (converted)) { XWPFDocument docx = new XWPFDocument (convertedStream); addWatermarkToWord(docx, watermarkImage, x, y, opacity); docx.write(outputStream); docx.close(); } finally { ZipSecureFile.setMinInflateRatio(0.001 ); } tmpDoc.delete(); converted.delete(); } else { ZipSecureFile.setMinInflateRatio(0.001 ); try { XWPFDocument doc = new XWPFDocument (cachedStream); addWatermarkToWord(doc, watermarkImage, x, y, opacity); doc.write(outputStream); doc.close(); } finally { ZipSecureFile.setMinInflateRatio(0.001 ); } } return new ByteArrayInputStream (outputStream.toByteArray()); } else { return fileInputStreamFile; } }
1.1 设计思路 方法定位 这是一个统一的文件水印入口方法 ,核心职责是:接收任意类型的文件流和水印图片流,根据文件类型自动分发到对应的处理逻辑,最终返回一个已添加水印的新文件流。
参数与返回值
参数
说明
fileInputStreamFile
原始文件的字节流(图片、PDF、Excel、Word 等)
watermarkInputStream
水印图片的字节流
fileType
文件类型标识(用于辅助判断 Excel/Word 的格式)
x, y
水印在页面上的坐标位置
opacity
透明度(0.0 ~ 1.0)
返回值
添加水印后的新文件流(InputStream)
核心设计思路 该方法采用了**”统一入口 + 类型分派”**的策略:
输入统一 :无论目标文件是图片、PDF、Office 文档,都通过 InputStream 接收。
类型探测 :通过 isImage、isPDF、isExcel、isWord 等方法识别真实文件类型。
分支处理 :针对不同文件格式,调用各自的 API(ImageIO、PDFBox、Apache POI)完成水印嵌入。
输出统一 :所有分支最终都将结果写入 ByteArrayOutputStream,再包装为 ByteArrayInputStream 返回。
逐段逻辑解析 预加载水印图片 1 watermarkImage = ImageIO.read(watermarkInputStream);
由于水印图片需要在各分支中复用,因此在方法开头一次性读取为 BufferedImage。如果读取失败,直接抛出 IOException 提前终止。
缓存原始文件字节 1 byte [] fileBytes = toByteArray(fileInputStreamFile);
toByteArray 的作用 :将输入流完整读取为字节数组。
为什么需要这一步 :InputStream 只能顺序读取一次,而后续的类型判断和实际处理都需要重新读取流。通过缓存字节数组,可以多次构造新的 ByteArrayInputStream 复用。
图片处理分支 1 2 3 4 5 6 if (isImage(new ByteArrayInputStream (fileBytes))) { originalImage = ImageIO.read(cachedStream); BufferedImage watermarkedImage = addWatermarkToImage(originalImage, watermarkImage, x, y, opacity); ImageIO.write(watermarkedImage, "png" , outputStream); return new ByteArrayInputStream (outputStream.toByteArray()); }
将原始图片读取为 BufferedImage,调用 addWatermarkToImage 生成新图,以 PNG 格式 写回。输出固定为 PNG,无论原图是 JPG 还是 BMP,最终都会统一为 PNG。
PDF 处理分支 1 2 3 4 5 6 7 8 9 10 11 12 13 PDDocument document = PDDocument.load(fileBytes);PDImageXObject pdImage = LosslessFactory.createFromImage(document, watermarkImage);for (PDPage page : document.getPages()) { try (PDPageContentStream contentStream = new PDPageContentStream (...)) { PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState (); graphicsState.setNonStrokingAlphaConstant(opacity); contentStream.setGraphicsStateParameters(graphicsState); contentStream.drawImage(pdImage, x, y); } } document.save(outputStream); document.close();
核心 API :Apache PDFBox。
透明度实现 :通过 PDExtendedGraphicsState 设置非描边 alpha 常量。
遍历所有页面 :PDF 水印需要逐页添加,保证每一页都有水印。
AppendMode.APPEND :在页面现有内容之上 追加水印,而非覆盖。
Excel 处理分支 1 2 3 4 5 6 7 8 boolean isOld = isOldWordFormat(fileBytes);if (isOld) { HSSFWorkbook workbook = new HSSFWorkbook (cachedStream); } else { XSSFWorkbook workbook = new XSSFWorkbook (cachedStream); } addWatermarkToExcel(workbook, transparentWatermark, x, y, opacity); workbook.write(outputStream);
格式兼容 :区分 Excel 2003(.xls,HSSFWorkbook)和 Excel 2007+(.xlsx,XSSFWorkbook)。
透明度预处理 :通过 createWatermarkWithOpacity 预先生成带透明度的水印图片,再传给 Excel 处理逻辑。
ZipSecureFile :设置最小压缩比,防止处理某些特殊构造的压缩包文件时抛出异常。
Word 处理分支 1 2 3 4 5 6 7 8 9 10 11 12 13 boolean isOldWord = isOldWordFormat(fileBytes);if (isOldWord) { File tmpDoc = File.createTempFile("tmpWord" , ".doc" ); Files.copy(cachedStream, tmpDoc.toPath(), ...); File converted = convertDocToDocx(tmpDoc); } else { XWPFDocument doc = new XWPFDocument (cachedStream); addWatermarkToWord(doc, watermarkImage, x, y, opacity); doc.write(outputStream); }
旧格式兼容 :.doc(Word 2003)无法直接用 XWPFDocument 处理,需要先通过 convertDocToDocx 临时转为 .docx。
临时文件清理 :转换过程中创建了临时文件,处理完毕后调用 delete() 清理。
兜底分支 1 2 3 } else { return fileInputStreamFile; }
如果以上类型都不匹配,直接返回原始文件流,保证程序不会崩溃,但也不会添加水印。
关键设计点总结
设计点
说明
流复用机制
原始文件流先转为字节数组缓存,后续多次通过 new ByteArrayInputStream(fileBytes) 重新构造流,解决 InputStream 不可重复读的问题。
统一返回
所有成功分支最终都返回 new ByteArrayInputStream(outputStream.toByteArray()),调用方无需关心内部格式差异。
格式自适应
通过文件头魔数(由 isImage、isPDF 等外部方法实现)判断真实类型,而非单纯依赖文件扩展名。
旧格式兼容
对 .xls、.doc 等旧版 Office 格式提供了降级处理路径(尤其是 Word 的 .doc → .docx 转换)。
透明度控制
图片/PDF 直接支持透明度参数;Excel/Word 则通过预先处理水印图片或利用原生 API 实现。
2. 辅助工具方法 2.1 文件类型判断 isWord1 2 3 4 5 6 7 8 9 10 11 private static boolean isWord (InputStream inputStream, String fileType) { if (ObjectUtil.equals("doc" , fileType) || ObjectUtil.equals("docx" , fileType)) { try { FileMagic fileMagic = FileMagic.valueOf(inputStream); return fileMagic == FileMagic.OLE2 || fileMagic == FileMagic.OOXML; } catch (Exception e) { return false ; } } return false ; }
首先通过 fileType 参数快速过滤,只有 "doc" 或 "docx" 才继续检测。使用 Apache POI 的 FileMagic.valueOf(inputStream) 读取文件头魔数,判断是否为 OLE2(旧版 .doc)或 OOXML(新版 .docx)。
FileMagic 通过文件头字节来识别文件类型,比单纯依赖文件扩展名更可靠。
isExcel1 2 3 4 5 6 7 8 9 10 11 private static boolean isExcel (InputStream inputStream, String fileType) { if (ObjectUtil.equals("xls" , fileType) || ObjectUtil.equals("xlsx" , fileType)) { try { FileMagic fileMagic = FileMagic.valueOf(inputStream); return fileMagic == FileMagic.OLE2 || fileMagic == FileMagic.OOXML; } catch (Exception e) { return false ; } } return false ; }
与 isWord 逻辑一致,先通过 fileType 过滤 "xls" 和 "xlsx",再通过文件头魔数确认。
isImage1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 private static boolean isImage (InputStream inputStream) throws IOException { byte [] header = new byte [8 ]; int bytesRead = inputStream.read(header); if (bytesRead < 4 ) { throw new IOException ("文件头数据不足,无法判断文件类型" ); } if (header[0 ] == (byte ) 0x89 && header[1 ] == 'P' && header[2 ] == 'N' && header[3 ] == 'G' ) { return true ; } else if (header[0 ] == (byte ) 0xFF && header[1 ] == (byte ) 0xD8 ) { return true ; } else if (header[0 ] == 'G' && header[1 ] == 'I' && header[2 ] == 'F' ) { return true ; } else if (header[0 ] == 'B' && header[1 ] == 'M' ) { return true ; } else if (header[0 ] == 'I' && header[1 ] == 'I' && header[2 ] == 0x2A && header[3 ] == 0x00 ) { return true ; } return false ; }
通过读取文件头魔数判断是否为常见图片格式:
格式
魔数特征
PNG
0x89 0x50 0x4E 0x47
JPG
0xFF 0xD8
GIF
"GIF"
BMP
"BM"
TIFF
"II" + 0x2A 0x00
该方法会消耗输入流的前几个字节,因此调用方需要基于可重复读取的流(如已缓存为字节数组后重新构造的 ByteArrayInputStream)。
isPDF1 2 3 4 5 6 private static boolean isPDF (InputStream inputStream) throws IOException { InputStream inputStreamFile = new BufferedInputStream (inputStream); byte [] header = new byte [5 ]; int read = inputStreamFile.read(header); return read >= 5 && new String (header).startsWith("%PDF-" ); }
将输入流包装为 BufferedInputStream,读取前 5 个字节,判断是否为 "%PDF-" 开头。
1 2 3 4 5 6 7 8 private static boolean isOldWordFormat (byte [] fileBytes) { try (InputStream is = new ByteArrayInputStream (fileBytes)) { FileMagic fileMagic = FileMagic.valueOf(is); return fileMagic == FileMagic.OLE2; } catch (Exception e) { return false ; } }
判断文件是否为旧版 Office 格式(OLE2,即 .doc、.xls 等)。在主方法中用于区分 .doc 和 .docx、.xls 和 .xlsx。
2.2 流处理工具方法 toByteArray1 2 3 4 5 6 7 8 9 10 private static byte [] toByteArray(InputStream inputStream) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream (); byte [] data = new byte [1024 ]; int bytesRead; while ((bytesRead = inputStream.read(data, 0 , data.length)) != -1 ) { buffer.write(data, 0 , bytesRead); } buffer.flush(); return buffer.toByteArray(); }
将 InputStream 完整读取为 byte[]。InputStream 只能顺序读取一次,通过转为字节数组,后续可以多次构造 ByteArrayInputStream 复用,这是整个工具类流复用机制 的基础。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 private static String extractPathFromUrl (String url) { int protocolEnd = url.indexOf("://" ); int start = 0 ; if (protocolEnd != -1 ) { start = protocolEnd + 3 ; } int hostEnd = url.indexOf('/' , start); if (hostEnd == -1 ) { return "/" ; } int queryOrFragment = url.length(); int queryIndex = url.indexOf('?' , hostEnd); int fragmentIndex = url.indexOf('#' , hostEnd); if (queryIndex != -1 ) queryOrFragment = Math.min(queryOrFragment, queryIndex); if (fragmentIndex != -1 ) queryOrFragment = Math.min(queryOrFragment, fragmentIndex); return url.substring(hostEnd, queryOrFragment); }
从 URL 字符串中提取路径部分:
输入:http://minio.example.com/bucket/file.pdf
输出:/bucket/file.pdf
decodePath1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 private static String decodePath (String path) { String[] segments = path.split("/" , -1 ); StringBuilder result = new StringBuilder (); for (int i = 0 ; i < segments.length; i++) { if (i > 0 ) result.append('/' ); String segment = segments[i]; if (segment.isEmpty()) continue ; try { String decoded = URLDecoder.decode(segment, StandardCharsets.UTF_8); result.append(decoded); } catch (IllegalArgumentException e) { result.append(segment); } } return result.toString(); }
对 URL 路径进行分段解码,兼容已编码和未编码的字符(如 %E6%98%86 → 昆),主要用于处理 MinIO 等存储系统中对象名称包含中文或特殊字符的情况。
2.3 文件格式转换 convertDocToDocx1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static File convertDocToDocx (File docFile) throws IOException, InterruptedException { File outputDir = docFile.getParentFile(); ProcessBuilder pb = new ProcessBuilder ( "soffice" , "--headless" , "--convert-to" , "docx" , docFile.getAbsolutePath(), "--outdir" , outputDir.getAbsolutePath() ); Process process = pb.start(); process.waitFor(); String docxName = docFile.getName().replaceAll("\\.doc$" , ".docx" ); return new File (outputDir, docxName); }
将 Word 2003(.doc)转换为 Word 2007+(.docx),依赖服务器安装的 LibreOffice(soffice 命令):
--headless:无头模式,不打开图形界面
--convert-to docx:指定输出格式
--outdir:指定输出目录
2.4 水印核心处理 addWatermarkToImage1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 private static BufferedImage addWatermarkToImage (BufferedImage originalImage, BufferedImage watermarkImage, int x, int y, float opacity) { if (ObjectUtil.isEmpty(originalImage)) { throw new IllegalArgumentException ("原始图像不能为空" ); } if (ObjectUtil.isEmpty(watermarkImage)) { throw new IllegalArgumentException ("水印图像不能为空" ); } BufferedImage result = new BufferedImage ( originalImage.getWidth(), originalImage.getHeight(), BufferedImage.TYPE_INT_ARGB ); Graphics2D g2d = result.createGraphics(); g2d.drawImage(originalImage, 0 , 0 , null ); g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); g2d.drawImage(watermarkImage, x, y, null ); g2d.dispose(); return result; }
创建一个新的 BufferedImage(TYPE_INT_ARGB 支持透明度),先绘制原图,再通过 AlphaComposite.SRC_OVER 设置透明度后绘制水印。
createWatermarkWithOpacity1 2 3 4 5 6 7 8 9 10 11 12 private static BufferedImage createWatermarkWithOpacity (BufferedImage originalWatermark, float opacity) { BufferedImage transparentImage = new BufferedImage ( originalWatermark.getWidth(), originalWatermark.getHeight(), BufferedImage.TYPE_INT_ARGB ); Graphics2D g2d = transparentImage.createGraphics(); g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); g2d.drawImage(originalWatermark, 0 , 0 , null ); g2d.dispose(); return transparentImage; }
生成带透明度效果的水印图片。某些格式(如 Excel、Word)不直接支持设置图片透明度参数,因此需要预先处理水印图片本身 ,生成一张”已经透明”的图片后再插入。
addWatermarkToExcel(旧版 .xls)1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 private static void addWatermarkToExcel (HSSFWorkbook workbook, BufferedImage watermarkImage, int x, int y, float opacity) throws IOException { BufferedImage transparentWatermark = createWatermarkWithOpacity(watermarkImage, opacity); ByteArrayOutputStream imgOut = new ByteArrayOutputStream (); ImageIO.write(transparentWatermark, "png" , imgOut); byte [] watermarkBytes = imgOut.toByteArray(); for (int i = 0 ; i < workbook.getNumberOfSheets(); i++) { HSSFSheet sheet = workbook.getSheetAt(i); HSSFPatriarch patriarch = sheet.createDrawingPatriarch(); HSSFClientAnchor anchor = new HSSFClientAnchor (); anchor.setCol1(x / 40 ); anchor.setRow1(y / 20 ); anchor.setCol2((x + watermarkImage.getWidth()) / 80 ); anchor.setRow2((y + watermarkImage.getHeight()) / 20 ); int pictureIdx = workbook.addPicture(watermarkBytes, Workbook.PICTURE_TYPE_PNG); patriarch.createPicture(anchor, pictureIdx); } }
给 Excel 2003(.xls)添加水印。遍历所有工作表,使用 HSSFPatriarch 绘图器和 HSSFClientAnchor 锚点定位水印位置。
addWatermarkToExcel(新版 .xlsx)1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 private static void addWatermarkToExcel (XSSFWorkbook workbook, BufferedImage watermarkImage, int x, int y, float opacity) throws IOException { BufferedImage transparentWatermark = createWatermarkWithOpacity(watermarkImage, opacity); ByteArrayOutputStream imgOut = new ByteArrayOutputStream (); ImageIO.write(transparentWatermark, "png" , imgOut); byte [] watermarkBytes = imgOut.toByteArray(); int pictureIdx = workbook.addPicture(watermarkBytes, Workbook.PICTURE_TYPE_PNG); for (int i = 0 ; i < workbook.getNumberOfSheets(); i++) { XSSFSheet sheet = workbook.getSheetAt(i); XSSFDrawing drawing = sheet.createDrawingPatriarch(); XSSFClientAnchor anchor = new XSSFClientAnchor (); anchor.setCol1(0 ); anchor.setRow1(0 ); anchor.setDx1(Units.toEMU(x)); anchor.setDy1(Units.toEMU(y)); anchor.setCol2(0 ); anchor.setRow2(0 ); anchor.setDx2(Units.toEMU(x + watermarkImage.getWidth())); anchor.setDy2(Units.toEMU(y + watermarkImage.getHeight())); XSSFPicture picture = drawing.createPicture(anchor, pictureIdx); picture.resize(); } }
与旧版类似,但使用 XSSFWorkbook 相关 API。关键区别是用 Units.toEMU() 将像素转换为 EMU(English Metric Units,Office 文档标准长度单位),支持更精确的位置控制。
addWatermarkToWord1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 private static void addWatermarkToWord (Object document, BufferedImage watermarkImage, int x, int y, float opacity) throws IOException { BufferedImage transparentWatermark = createWatermarkWithOpacity(watermarkImage, opacity); ByteArrayOutputStream imgOut = new ByteArrayOutputStream (); ImageIO.write(transparentWatermark, "png" , imgOut); byte [] watermarkBytes = imgOut.toByteArray(); if (document instanceof XWPFDocument) { XWPFDocument doc = (XWPFDocument) document; try { int width = watermarkImage.getWidth(); int height = watermarkImage.getHeight(); XWPFHeader header = doc.createHeader(HeaderFooterType.DEFAULT); XWPFParagraph headerPara = header.createParagraph(); headerPara.setAlignment(ParagraphAlignment.CENTER); XWPFRun headerRun = headerPara.createRun(); insertFloatingPicture(headerRun, watermarkBytes, width, height, x, y); } catch (Exception e) { e.printStackTrace(); } } }
将水印插入页眉 而非正文段落或表格中,这样水印会出现在每一页的固定位置,且不影响正文排版。
getAnchorWithGraphic1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public static CTAnchor getAnchorWithGraphic (CTGraphicalObject ctGraphicalObject, String deskFileName, int width, int height, int leftOffset, int topOffset, boolean behind) { String anchorXML = "" + "<wp:anchor xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" " + " simplePos=\"0\" relativeHeight=\"0\" behindDoc=\"" + (behind ? 1 : 0 ) + "\" " + " locked=\"0\" layoutInCell=\"1\" allowOverlap=\"1\">" + " <wp:simplePos x=\"0\" y=\"0\"/>" + " <wp:positionH relativeFrom=\"column\">" + " <wp:posOffset>" + leftOffset + "</wp:posOffset>" + " </wp:positionH>" + " <wp:positionV relativeFrom=\"line\">" + " <wp:posOffset>" + topOffset + "</wp:posOffset>" + " </wp:positionV>" + " <wp:extent cx=\"" + width + "\" cy=\"" + height + "\"/>" + " <wp:effectExtent l=\"0\" t=\"0\" r=\"0\" b=\"0\"/>" + " <wp:wrapNone/>" + " <wp:docPr id=\"1\" name=\"Drawing 0\" descr=\"" + deskFileName + "\"/>" + " <wp:cNvGraphicFramePr/>" + "</wp:anchor>" ; try { CTDrawing drawing = CTDrawing.Factory.parse(anchorXML); CTAnchor anchor = drawing.getAnchorArray(0 ); anchor.setGraphic(ctGraphicalObject); return anchor; } catch (XmlException e) { e.printStackTrace(); return null ; } }
创建 Word 浮动图片的锚点(CTAnchor)。通过拼接 OpenXML 的 wp:anchor 字符串来设置:
水平位置(positionH,相对 column)、垂直位置(positionV,相对 line)
图片尺寸(extent)
behind 参数控制图片在文字上方还是下方
wrapNone 表示文字不环绕图片
insertFloatingPicture1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 private static void insertFloatingPicture (XWPFRun run, byte [] imageBytes, int widthPx, int heightPx, int offsetXPx, int offsetYPx) { try (ByteArrayInputStream bis = new ByteArrayInputStream (imageBytes)) { run.addPicture(bis, XWPFDocument.PICTURE_TYPE_PNG, "watermark" , Units.toEMU(widthPx), Units.toEMU(heightPx)); CTDrawing drawing = run.getCTR().getDrawingArray(0 ); CTInline inline = drawing.getInlineArray(0 ); CTGraphicalObject graphic = inline.getGraphic(); CTAnchor anchor = getAnchorWithGraphic( graphic, "watermark" , Units.toEMU(widthPx), Units.toEMU(heightPx), Units.toEMU(offsetXPx), Units.toEMU(offsetYPx), false ); drawing.setAnchorArray(new CTAnchor []{anchor}); drawing.removeInline(0 ); } catch (Exception e) { e.printStackTrace(); } }
在 Word 的某个 XWPFRun 中插入浮动 图片。Word 中的图片默认是 Inline(内联)的,会随文字流动;而水印需要浮动在页面固定位置,因此需要将内联图片转换为浮动图片(Anchor),浮于文字上方(behindDoc=false),不干扰文字排版。
3. 依赖列表 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import cn.hutool.core.util.ObjectUtil;import org.apache.pdfbox.pdmodel.PDDocument;import org.apache.pdfbox.pdmodel.PDPage;import org.apache.pdfbox.pdmodel.PDPageContentStream;import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;import org.apache.poi.hssf.usermodel.HSSFClientAnchor;import org.apache.poi.hssf.usermodel.HSSFPatriarch;import org.apache.poi.hssf.usermodel.HSSFSheet;import org.apache.poi.hssf.usermodel.HSSFWorkbook;import org.apache.poi.openxml4j.util.ZipSecureFile;import org.apache.poi.poifs.filesystem.FileMagic;import org.apache.poi.ss.usermodel.Workbook;import org.apache.poi.util.Units;import org.apache.poi.wp.usermodel.HeaderFooterType;import org.apache.poi.xssf.usermodel.*;import org.apache.poi.xwpf.usermodel.*;import org.apache.xmlbeans.XmlException;import org.openxmlformats.schemas.drawingml.x2006.main.CTGraphicalObject;import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTAnchor;import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTInline;import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTDrawing;import javax.imageio.ImageIO;import java.awt.*;import java.awt.image.BufferedImage;import java.io.*;import java.net.URLDecoder;import java.nio.file.Files;import java.nio.file.StandardCopyOption;
对应 Maven 依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <dependency > <groupId > cn.hutool</groupId > <artifactId > hutool-all</artifactId > </dependency > <dependency > <groupId > org.apache.pdfbox</groupId > <artifactId > pdfbox</artifactId > </dependency > <dependency > <groupId > org.apache.poi</groupId > <artifactId > poi-ooxml</artifactId > </dependency > <dependency > <groupId > org.apache.poi</groupId > <artifactId > poi-scratchpad</artifactId > </dependency >
4. 总结 这套通用方法覆盖了图片、PDF、Excel、Word 四大类常见文件的水印添加需求,核心思路是统一入口 + 类型分派:
优点 :调用方只需传入文件流和水印流,无需关心底层 API 差异;支持新旧版 Office 格式。
局限性 :水印不支持旋转;不同格式文件下水印位置难以统一把控;.doc 格式依赖服务器安装 LibreOffice。
如果项目预算允许,可以考虑商业组件(如 Aspose 系列),对 Office 文档的水印支持会更加完善。但对于大多数轻量场景,这套免费方案已经足够实用。