package jnpf.fileStorage.dms; import com.google.zxing.BarcodeFormat; import com.google.zxing.EncodeHintType; import com.google.zxing.MultiFormatWriter; import com.google.zxing.common.BitMatrix; import jnpf.dmsEntity.preview.model.DmsWatermarkPositionSnapshot; import jnpf.dmsEntity.preview.model.DmsWatermarkSnapshot; import jnpf.dms.fonts.DmsWatermarkFonts; import jnpf.exception.DataException; import jnpf.onlyoffice.PageWatermarkRequest; import org.docx4j.XmlUtils; import org.docx4j.finders.SectPrFinder; import org.docx4j.model.structure.HeaderFooterPolicy; import org.docx4j.model.structure.SectionWrapper; import org.docx4j.openpackaging.packages.WordprocessingMLPackage; import org.docx4j.openpackaging.parts.PartName; import org.docx4j.openpackaging.parts.WordprocessingML.BinaryPartAbstractImage; import org.docx4j.openpackaging.parts.WordprocessingML.HeaderPart; import org.docx4j.relationships.Relationship; import org.docx4j.wml.HdrFtrRef; import org.docx4j.wml.HeaderReference; import org.docx4j.wml.ObjectFactory; import org.docx4j.wml.P; import org.docx4j.wml.SectPr; import org.springframework.stereotype.Service; import org.springframework.web.util.UriUtils; import org.w3c.dom.Element; import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.awt.AlphaComposite; import java.awt.Color; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.font.FontRenderContext; import java.awt.font.TextLayout; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.StringWriter; import java.math.BigDecimal; import java.math.BigInteger; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @Service public class DmsDocxWatermarkService { private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; private static final String R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; private static final String WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; private static final String A = "http://schemas.openxmlformats.org/drawingml/2006/main"; private static final String PIC = "http://schemas.openxmlformats.org/drawingml/2006/picture"; private static final String WPS = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"; private static final String W14 = "http://schemas.microsoft.com/office/word/2010/wordml"; private static final Pattern PAGE_VARIABLE = Pattern.compile("\\{/((?:Curr|Total)Page)\\}"); private static final String FILE_URL_PREFIX = "/api/file/Image/"; private static final Map NAMESPACES; private static final double DPI = 144D; private static final int MAX_TILED_MARKS = 300; private static final int MAX_IMAGE_WATERMARKS = 16; private static final int MAX_IMAGE_DRAW_WORK_MULTIPLIER = 8; private static final long MAX_IMAGE_MARK_SOURCE_BYTES = 10L * 1024L * 1024L; private static final int MAX_IMAGE_MARK_DIMENSION = 8_192; private static final long MAX_IMAGE_MARK_PIXELS = 4_000_000L; private static final int MAX_IMAGE_CODE128_CHARACTERS = 256; private static final int MAX_RASTER_MARK_DIMENSION = 16_384; private static final long MAX_RASTER_MARK_PIXELS = 16_000_000L; private static final int BARCODE_TEXT_FONT_SIZE = 10; private static final int BARCODE_TEXT_PADDING_PX = 2; private static final ObjectFactory WML = new ObjectFactory(); static { Map namespaces = new LinkedHashMap<>(); namespaces.put("w", W); namespaces.put("r", R); namespaces.put("wp", WP); namespaces.put("a", A); namespaces.put("pic", PIC); namespaces.put("wps", WPS); namespaces.put("w14", W14); NAMESPACES = Collections.unmodifiableMap(namespaces); } private final DmsCommonFileStorage commonFileStorage; public DmsDocxWatermarkService(DmsCommonFileStorage commonFileStorage) { this.commonFileStorage = commonFileStorage; } public byte[] apply(byte[] source, List watermarks) { if (watermarks == null || watermarks.isEmpty()) { return source; } if (watermarks.stream().anyMatch(mark -> mark.getRenderPages() != null)) { throw new DataException("指定页水印必须通过按页渲染服务处理"); } try { WordprocessingMLPackage word = WordprocessingMLPackage.load(new ByteArrayInputStream(source)); Map targets = effectiveHeaders(word); List staticMarks = new ArrayList<>(); List pageMarks = new ArrayList<>(); for (DmsWatermarkSnapshot watermark : watermarks) { if ("TEXT".equalsIgnoreCase(value(watermark.getWatermarkType(), "TEXT")) && PAGE_VARIABLE.matcher(value(watermark.getContent(), "")).find()) { pageMarks.add(watermark); } else { staticMarks.add(watermark); } } int drawingId = 1000; for (Map.Entry target : targets.entrySet()) { if (!staticMarks.isEmpty()) { byte[] overlay = renderOverlay(target.getValue(), staticMarks); BinaryPartAbstractImage image = BinaryPartAbstractImage.createImagePart( word, target.getKey(), overlay); target.getKey().getContent().add(anchor(image.getRelLast().getId(), target.getValue(), drawingId++)); } for (DmsWatermarkSnapshot watermark : pageMarks) { for (Point point : points(target.getValue(), watermark)) { target.getKey().getContent().add(pageFieldAnchor(watermark, point, drawingId++)); } } } if (!pageMarks.isEmpty()) { org.docx4j.wml.BooleanDefaultTrue update = new org.docx4j.wml.BooleanDefaultTrue(); update.setVal(true); org.docx4j.openpackaging.parts.WordprocessingML.DocumentSettingsPart settings = word.getMainDocumentPart().getDocumentSettingsPart(); if (settings == null) { settings = new org.docx4j.openpackaging.parts.WordprocessingML.DocumentSettingsPart(); settings.setJaxbElement(WML.createCTSettings()); word.getMainDocumentPart().addTargetPart(settings); } settings.getJaxbElement().setUpdateFields(update); } ByteArrayOutputStream output = new ByteArrayOutputStream(); word.save(output); return output.toByteArray(); } catch (DataException exception) { throw exception; } catch (Exception exception) { throw new DataException("生成 DOCX 水印失败:" + message(exception)); } } /** Reuse the existing bitmap/layout renderer for every distinct paper size in the source. */ List pageOverlays(byte[] source, List marks) throws Exception { WordprocessingMLPackage word = WordprocessingMLPackage.load(new ByteArrayInputStream(source)); Map sizes = new LinkedHashMap<>(); for (SectionWrapper section : word.getDocumentModel().getSections()) { PageSize size = pageSize(section); sizes.put(size.width + ":" + size.height, size); } if (sizes.isEmpty()) sizes.put("A4", new PageSize(210D, 297D)); if (sizes.size() > 64) throw new DataException("文档纸张规格过多,无法生成指定页水印"); List overlays = new ArrayList<>(); long totalImageBytes = 0; for (DmsWatermarkSnapshot mark : marks) { if (mark.getRenderPages() == null || mark.getRenderPages().isEmpty()) { throw new DataException("指定页水印缺少页码数组"); } // Each entry gets an independent drawing, including entries targeting the same page. List variants = new ArrayList<>(); for (PageSize size : sizes.values()) { PositionedImage positioned = positionedImage(size, mark); byte[] image = positioned.png; totalImageBytes += image.length; if (totalImageBytes > 32_000_000) throw new DataException("指定页水印图片超过处理上限"); variants.add(new PageWatermarkRequest.Variant(size.width, size.height, positioned.width, positioned.height, positioned.x, positioned.y, java.util.Base64.getEncoder().encodeToString(image))); } overlays.add(new PageWatermarkRequest.Overlay(List.copyOf(mark.getRenderPages()), variants)); } return overlays; } private PositionedImage positionedImage(PageSize page, DmsWatermarkSnapshot watermark) throws Exception { if ("TILED".equalsIgnoreCase(watermark.getLayoutMode())) { return new PositionedImage(renderOverlay(page, Collections.singletonList(watermark)), page.width, page.height, 0D, 0D); } double width = positive(watermark.getMarkWidthMm(), 80D); double height = positive(watermark.getMarkHeightMm(), 20D); Point point = points(page, watermark).get(0); double radians = Math.toRadians(Math.floorMod(value(watermark.getRotationAngle(), 0), 360)); double drawingWidth = Math.abs(width * Math.cos(radians)) + Math.abs(height * Math.sin(radians)); double drawingHeight = Math.abs(width * Math.sin(radians)) + Math.abs(height * Math.cos(radians)); int canvasWidth = Math.max(1, px(drawingWidth)); int canvasHeight = Math.max(1, px(drawingHeight)); BufferedImage image = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D graphics = image.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); float opacity = Math.max(0, Math.min(100, value(watermark.getOpacity(), 100))) / 100F; graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); graphics.translate(canvasWidth / 2D, canvasHeight / 2D); graphics.rotate(radians); graphics.drawImage(renderMark(watermark, width, height), -px(width) / 2, -px(height) / 2, px(width), px(height), null); } finally { graphics.dispose(); } ByteArrayOutputStream output = new ByteArrayOutputStream(); if (!ImageIO.write(image, "png", output)) { throw new DataException("当前运行环境不能生成 PNG 水印图层"); } double x = clamp(point.x - (drawingWidth - width) / 2D, 0D, Math.max(0D, page.width - drawingWidth)); double y = clamp(point.y - (drawingHeight - height) / 2D, 0D, Math.max(0D, page.height - drawingHeight)); return new PositionedImage(output.toByteArray(), drawingWidth, drawingHeight, x, y); } private Map effectiveHeaders(WordprocessingMLPackage word) throws Exception { Map result = new IdentityHashMap<>(); List sections = word.getDocumentModel().getSections(); if (sections.isEmpty()) { SectPr sectPr = defaultSectPr(); word.getMainDocumentPart().getJaxbElement().getBody().setSectPr(sectPr); result.put(createDefaultHeader(word, sectPr), new PageSize(210D, 297D)); return result; } SectPrFinder finder = new SectPrFinder(word.getMainDocumentPart()); new org.docx4j.TraversalUtil(word.getMainDocumentPart().getJaxbElement(), finder); List actualSections = finder.getOrderedSectPrList(); for (int index = 0; index < sections.size(); index++) { SectionWrapper section = sections.get(index); PageSize size = pageSize(section); HeaderFooterPolicy policy = section.getHeaderFooterPolicy(); Set headers = Collections.newSetFromMap(new IdentityHashMap<>()); if (policy != null) { add(headers, policy.getDefaultHeader()); add(headers, policy.getFirstHeader()); add(headers, policy.getEvenHeader()); } if (headers.isEmpty()) { SectPr actual = index == sections.size() - 1 ? word.getMainDocumentPart().getJaxbElement().getBody().getSectPr() : (index < actualSections.size() ? actualSections.get(index) : section.getSectPr()); if (actual == null && index == sections.size() - 1) { actual = section.getSectPr() == null ? defaultSectPr() : section.getSectPr(); word.getMainDocumentPart().getJaxbElement().getBody().setSectPr(actual); } headers.add(createDefaultHeader(word, actual)); } for (HeaderPart header : headers) { result.putIfAbsent(header, size); } } return result; } private SectPr defaultSectPr() { SectPr sectPr = WML.createSectPr(); SectPr.PgSz size = WML.createSectPrPgSz(); size.setW(BigInteger.valueOf(11906)); size.setH(BigInteger.valueOf(16838)); sectPr.setPgSz(size); return sectPr; } private void add(Set headers, HeaderPart header) { if (header != null) { headers.add(header); } } private HeaderPart createDefaultHeader(WordprocessingMLPackage word, SectPr sectPr) throws Exception { HeaderPart header = new HeaderPart(new PartName("/word/header-dms-" + Math.abs(java.util.UUID.randomUUID().getMostSignificantBits()) + ".xml")); header.init(); Relationship relationship = word.getMainDocumentPart().addTargetPart(header); HeaderReference reference = WML.createHeaderReference(); reference.setId(relationship.getId()); reference.setType(HdrFtrRef.DEFAULT); if (sectPr == null) { throw new DataException("DOCX 分节缺少页面设置"); } sectPr.getEGHdrFtrReferences().add(reference); return header; } private PageSize pageSize(SectionWrapper section) { SectPr.PgSz pgSz = section.getPageDimensions().getPgSz(); double width = pgSz == null || pgSz.getW() == null ? 210D : pgSz.getW().doubleValue() * 25.4D / 1440D; double height = pgSz == null || pgSz.getH() == null ? 297D : pgSz.getH().doubleValue() * 25.4D / 1440D; return new PageSize(width, height); } byte[] renderPdfOverlay(double widthMm, double heightMm, List watermarks) throws Exception { return renderOverlay(new PageSize(widthMm, heightMm), watermarks); } /** Use the export renderer for the configuration dialog, including custom TTC fonts. */ public byte[] preview(double widthMm, double heightMm, DmsWatermarkSnapshot watermark) throws Exception { if (!Double.isFinite(widthMm) || !Double.isFinite(heightMm) || widthMm <= 0 || heightMm <= 0 || widthMm > 500 || heightMm > 500 || watermark == null || watermark.getPositions() == null || watermark.getPositions().isEmpty() || watermark.getPositions().size() > 16) { throw new DataException("水印预览页面配置无效"); } if (watermark.getContent() != null && watermark.getContent().length() > 2000 || watermark.getFontSize() != null && (watermark.getFontSize() < 6 || watermark.getFontSize() > 144) || watermark.getRotationAngle() != null && Math.abs((long) watermark.getRotationAngle()) > 180 || watermark.getOpacity() != null && (watermark.getOpacity() < 0 || watermark.getOpacity() > 100)) { throw new DataException("水印预览内容或效果配置无效"); } for (BigDecimal dimension : new BigDecimal[]{watermark.getMarkWidthMm(), watermark.getMarkHeightMm()}) { if (dimension != null && (dimension.signum() <= 0 || dimension.compareTo(BigDecimal.valueOf(500)) > 0)) { throw new DataException("水印预览宽高超出范围"); } } for (DmsWatermarkPositionSnapshot position : watermark.getPositions()) { if (position == null) throw new DataException("水印预览位置配置无效"); for (BigDecimal coordinate : new BigDecimal[]{position.getPaperWidthMm(), position.getPaperHeightMm(), position.getPortraitXMm(), position.getPortraitYMm(), position.getLandscapeXMm(), position.getLandscapeYMm()}) { if (coordinate != null && (coordinate.signum() < 0 || coordinate.compareTo(BigDecimal.valueOf(500)) > 0)) { throw new DataException("水印预览位置超出范围"); } } } if ("BARCODE".equalsIgnoreCase(watermark.getWatermarkType()) && !"QR_CODE".equalsIgnoreCase(watermark.getBarcodeFormat()) && value(watermark.getContent(), "").length() > MAX_IMAGE_CODE128_CHARACTERS) { throw new DataException("条码预览内容过长"); } return renderPdfOverlay(widthMm, heightMm, Collections.singletonList(watermark)); } private byte[] renderOverlay(PageSize page, List watermarks) throws Exception { BufferedImage canvas = new BufferedImage(px(page.width), px(page.height), BufferedImage.TYPE_INT_ARGB); Graphics2D graphics = canvas.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); for (DmsWatermarkSnapshot watermark : watermarks) { renderWatermark(graphics, page, watermark); } } finally { graphics.dispose(); } ByteArrayOutputStream output = new ByteArrayOutputStream(); if (!ImageIO.write(canvas, "png", output)) { throw new DataException("当前运行环境不能生成 PNG 水印图层"); } return output.toByteArray(); } /** * Renders the existing millimetre-based watermark configuration on a raster image. * The closest configured paper ratio supplies the reference coordinate system, while * the watermark itself keeps its aspect ratio on non-paper-sized images. */ void applyImageWatermarks(BufferedImage canvas, List watermarks) throws Exception { if (canvas == null || canvas.getWidth() <= 0 || canvas.getHeight() <= 0) { throw new DataException("图片尺寸无效,无法生成水印"); } if (watermarks == null || watermarks.isEmpty()) { return; } if (watermarks.size() > MAX_IMAGE_WATERMARKS) { throw new DataException("单张图片最多支持 " + MAX_IMAGE_WATERMARKS + " 个水印"); } long remainingWork = (long) canvas.getWidth() * canvas.getHeight() * MAX_IMAGE_DRAW_WORK_MULTIPLIER; Graphics2D graphics = canvas.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); for (DmsWatermarkSnapshot watermark : watermarks) { remainingWork = renderImageWatermark(graphics, canvas.getWidth(), canvas.getHeight(), watermark, remainingWork); } } finally { graphics.dispose(); } } private long renderImageWatermark(Graphics2D graphics, int canvasWidth, int canvasHeight, DmsWatermarkSnapshot watermark, long remainingWork) throws Exception { if (watermark == null) { throw new DataException("图片水印配置无效"); } ImageReference reference = closestImageReference(canvasWidth, canvasHeight, watermark.getPositions()); double scaleX = canvasWidth / reference.width; double scaleY = canvasHeight / reference.height; double markScale = Math.min(scaleX, scaleY); double widthMm = positive(watermark.getMarkWidthMm(), 80D); double heightMm = positive(watermark.getMarkHeightMm(), 20D); double markWidth = Math.max(1D, widthMm * markScale); double markHeight = Math.max(1D, heightMm * markScale); double originX = reference.x * scaleX; double originY = reference.y * scaleY; validateImageWatermarkGeometry(markWidth, markHeight, originX, originY); List points; if ("TILED".equalsIgnoreCase(watermark.getLayoutMode())) { points = tiledPoints(new PageSize(canvasWidth, canvasHeight), originX, originY, markWidth, markHeight, nonNegative(watermark.getRepeatSpacingXMm(), 20D) * scaleX, nonNegative(watermark.getRepeatSpacingYMm(), 20D) * scaleY); } else { points = Collections.singletonList(new Point(originX, originY)); } int targetWidth = imageTargetDimension(markWidth); int targetHeight = imageTargetDimension(markHeight); int rotationAngle = value(watermark.getRotationAngle(), 0); List visiblePoints = new ArrayList<>(); long drawWork = 0L; for (Point point : points) { long visiblePixels = visiblePixels(point, targetWidth, targetHeight, canvasWidth, canvasHeight, rotationAngle); if (visiblePixels > 0L) { visiblePoints.add(point); drawWork += visiblePixels; } } if (drawWork > remainingWork) { throw new DataException("图片水印绘制量超过处理上限"); } if (visiblePoints.isEmpty()) { return remainingWork; } RasterSize raster = boundedRasterSize(targetWidth, targetHeight, canvasWidth, canvasHeight); double renderScale = Math.min(raster.width / (double) Math.max(1, px(widthMm)), raster.height / (double) Math.max(1, px(heightMm))); BufferedImage mark = renderMarkPixels(watermark, raster.width, raster.height, renderScale, true); float opacity = Math.max(0, Math.min(100, value(watermark.getOpacity(), 100))) / 100F; boolean barcode = "BARCODE".equalsIgnoreCase(value(watermark.getWatermarkType(), "TEXT")); for (Point point : visiblePoints) { Graphics2D layer = (Graphics2D) graphics.create(); try { layer.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); layer.setRenderingHint(RenderingHints.KEY_INTERPOLATION, barcode ? RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR : RenderingHints.VALUE_INTERPOLATION_BICUBIC); int x = (int) Math.round(point.x); int y = (int) Math.round(point.y); layer.rotate(Math.toRadians(rotationAngle), x + targetWidth / 2D, y + targetHeight / 2D); layer.drawImage(mark, x, y, targetWidth, targetHeight, null); } finally { layer.dispose(); } } return remainingWork - drawWork; } private void validateImageWatermarkGeometry(double width, double height, double x, double y) { if (!Double.isFinite(width) || !Double.isFinite(height) || !Double.isFinite(x) || !Double.isFinite(y)) { throw new DataException("图片水印尺寸或位置无效"); } } private int imageTargetDimension(double value) { long rounded = Math.round(value); if (rounded <= 0L || rounded > Integer.MAX_VALUE) { throw new DataException("图片水印绘制量超过处理上限"); } return (int) rounded; } private RasterSize boundedRasterSize(int targetWidth, int targetHeight, int canvasWidth, int canvasHeight) { double scale = Math.min(1D, Math.min(canvasWidth / (double) targetWidth, canvasHeight / (double) targetHeight)); scale = Math.min(scale, Math.min(MAX_RASTER_MARK_DIMENSION / (double) targetWidth, MAX_RASTER_MARK_DIMENSION / (double) targetHeight)); double pixels = (double) targetWidth * targetHeight; if (pixels * scale * scale > MAX_RASTER_MARK_PIXELS) { scale = Math.min(scale, Math.sqrt(MAX_RASTER_MARK_PIXELS / pixels)); } return new RasterSize(Math.max(1, (int) Math.round(targetWidth * scale)), Math.max(1, (int) Math.round(targetHeight * scale))); } private long visiblePixels(Point point, int width, int height, int canvasWidth, int canvasHeight, int rotationAngle) { double radians = Math.toRadians(Math.floorMod(rotationAngle, 360)); double rotatedWidth = Math.abs(width * Math.cos(radians)) + Math.abs(height * Math.sin(radians)); double rotatedHeight = Math.abs(width * Math.sin(radians)) + Math.abs(height * Math.cos(radians)); double left = point.x - (rotatedWidth - width) / 2D; double top = point.y - (rotatedHeight - height) / 2D; double visibleWidth = Math.max(0D, Math.min(canvasWidth, left + rotatedWidth) - Math.max(0D, left)); double visibleHeight = Math.max(0D, Math.min(canvasHeight, top + rotatedHeight) - Math.max(0D, top)); return (long) Math.ceil(visibleWidth) * (long) Math.ceil(visibleHeight); } private ImageReference closestImageReference(int canvasWidth, int canvasHeight, List positions) { if (positions == null || positions.isEmpty()) { throw new DataException("水印配置缺少页面位置"); } boolean landscape = canvasWidth > canvasHeight; double imageRatio = (double) canvasWidth / canvasHeight; ImageReference best = null; double bestDistance = Double.MAX_VALUE; for (DmsWatermarkPositionSnapshot position : positions) { double width = positive(position.getPaperWidthMm(), 210D); double height = positive(position.getPaperHeightMm(), 297D); if (landscape != (width > height)) { double swapped = width; width = height; height = swapped; } double distance = Math.abs(Math.log(imageRatio / (width / height))); if (distance < bestDistance) { BigDecimal x = landscape ? position.getLandscapeXMm() : position.getPortraitXMm(); BigDecimal y = landscape ? position.getLandscapeYMm() : position.getPortraitYMm(); best = new ImageReference(width, height, x == null ? 0D : x.doubleValue(), y == null ? 0D : y.doubleValue()); bestDistance = distance; } } return best; } private void renderWatermark(Graphics2D graphics, PageSize page, DmsWatermarkSnapshot watermark) throws Exception { double width = positive(watermark.getMarkWidthMm(), 80D); double height = positive(watermark.getMarkHeightMm(), 20D); List points = points(page, watermark); BufferedImage mark = renderMark(watermark, width, height); float opacity = Math.max(0, Math.min(100, value(watermark.getOpacity(), 100))) / 100F; for (Point point : points) { Graphics2D layer = (Graphics2D) graphics.create(); try { layer.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); int x = pixelOffset(point.x); int y = pixelOffset(point.y); int markWidth = px(width); int markHeight = px(height); layer.rotate(Math.toRadians(value(watermark.getRotationAngle(), 0)), x + markWidth / 2D, y + markHeight / 2D); layer.drawImage(mark, x, y, markWidth, markHeight, null); } finally { layer.dispose(); } } } private BufferedImage renderMark(DmsWatermarkSnapshot watermark, double width, double height) throws Exception { int imageWidth = Math.max(1, px(width)); int imageHeight = Math.max(1, px(height)); return renderMarkPixels(watermark, imageWidth, imageHeight, 1D, false); } private BufferedImage renderMarkPixels(DmsWatermarkSnapshot watermark, int imageWidth, int imageHeight, double renderScale, boolean imageTarget) throws Exception { String type = value(watermark.getWatermarkType(), "TEXT").toUpperCase(Locale.ROOT); if ("IMAGE".equals(type)) { return fitImage(loadImage(watermark.getImageUrl()), imageWidth, imageHeight); } if ("BARCODE".equals(type)) { return barcode(watermark, imageWidth, imageHeight, renderScale, imageTarget); } return text(watermark, imageWidth, imageHeight, renderScale); } private BufferedImage text(DmsWatermarkSnapshot watermark, int width, int height) { return text(watermark, width, height, 1D); } private BufferedImage text(DmsWatermarkSnapshot watermark, int width, int height, double renderScale) { String content = value(watermark.getContent(), ""); String[] lines = content.split("\\R", -1); int requestedSize = Math.max(1, value(watermark.getFontSize(), 24)); Font font = chooseFont(watermark.getFontFamily(), content, requestedSize, renderScale); TextLayout[] layouts = new TextLayout[lines.length]; Rectangle[] lineBounds = new Rectangle[lines.length]; String horizontal = horizontalAlignment(watermark); Rectangle ink = null; int lineHeight; BufferedImage sample = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); Graphics2D measure = sample.createGraphics(); try { measure.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); measure.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); measure.setFont(font); FontMetrics metrics = measure.getFontMetrics(); lineHeight = metrics.getHeight(); FontRenderContext context = measure.getFontRenderContext(); for (int i = 0; i < lines.length; i++) { if (lines[i].isEmpty()) { continue; } layouts[i] = new TextLayout(lines[i], font, context); Rectangle lineInk = layouts[i].getPixelBounds(context, 0, i * lineHeight); lineBounds[i] = lineInk; if (!lineInk.isEmpty()) { ink = ink == null ? lineInk : ink.union(lineInk); } } } finally { measure.dispose(); } if (ink == null) { return new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); } int padding = Math.min(128, Math.max(4, font.getSize())); BufferedImage temporary = new BufferedImage(Math.addExact(width, padding * 2), Math.addExact(height, padding * 2), BufferedImage.TYPE_INT_ARGB); Graphics2D graphics = temporary.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); graphics.setColor(parseColor(watermark.getFontColor())); for (int i = 0; i < layouts.length; i++) { if (layouts[i] != null) { Rectangle bounds = lineBounds[i]; int x = "LEFT".equals(horizontal) ? padding - ink.x : padding - bounds.x + alignmentOffset(width, bounds.width, horizontal); layouts[i].draw(graphics, x, padding + i * lineHeight - ink.y); } } } finally { graphics.dispose(); } return alignTextInk(temporary, width, height, "LEFT".equals(horizontal) ? null : padding, verticalAlignment(watermark)); } private BufferedImage alignTextInk(BufferedImage source, int width, int height, Integer horizontalOrigin, String vertical) { int sourceWidth = source.getWidth(); int left = sourceWidth; int top = source.getHeight(); int bottom = -1; int[] pixels = ((DataBufferInt) source.getRaster().getDataBuffer()).getData(); for (int y = 0; y < source.getHeight(); y++) { for (int x = 0; x < sourceWidth; x++) { if ((pixels[y * sourceWidth + x] >>> 24) != 0) { left = Math.min(left, x); top = Math.min(top, y); bottom = Math.max(bottom, y); } } } BufferedImage aligned = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); if (left == sourceWidth) { return aligned; } Graphics2D graphics = aligned.createGraphics(); try { int y = alignmentOffset(height, bottom - top + 1, vertical) - top; // Keep the per-line X offsets; trimming the whole image would undo centering. graphics.drawImage(source, -(horizontalOrigin == null ? left : horizontalOrigin), y, null); } finally { graphics.dispose(); } return aligned; } private int alignmentOffset(int available, int content, String alignment) { int remaining = Math.max(0, available - content); return switch (alignment) { case "CENTER", "MIDDLE" -> remaining / 2; case "RIGHT", "BOTTOM" -> remaining; default -> 0; }; } private String horizontalAlignment(DmsWatermarkSnapshot watermark) { return "POSITION".equalsIgnoreCase(value(watermark.getLayoutMode(), "POSITION")) ? value(watermark.getHorizontalAlign(), "LEFT").toUpperCase(Locale.ROOT) : "LEFT"; } private String verticalAlignment(DmsWatermarkSnapshot watermark) { return "POSITION".equalsIgnoreCase(value(watermark.getLayoutMode(), "POSITION")) ? value(watermark.getVerticalAlign(), "TOP").toUpperCase(Locale.ROOT) : "TOP"; } private Font chooseFont(String requested, String content, int points) { return chooseFont(requested, content, points, 1D); } private Font chooseFont(String requested, String content, int points, double renderScale) { return DmsWatermarkFonts.configured().choose(requested, content, Math.max(1, (int) Math.round(points * DPI / 72D * Math.max(0.01D, renderScale)))); } private Color parseColor(String value) { String color = value(value, "#666666").trim(); try { if (color.matches("#[0-9a-fA-F]{6}")) { return Color.decode(color); } if (color.matches("#[0-9a-fA-F]{8}")) { return new Color((int) Long.parseLong(color.substring(1), 16), true); } } catch (NumberFormatException ignored) { // Configuration validation normally prevents this fallback. } return Color.DARK_GRAY; } BufferedImage barcode(DmsWatermarkSnapshot watermark, int width, int height) throws Exception { return barcode(watermark, width, height, 1D, false); } private BufferedImage barcode(DmsWatermarkSnapshot watermark, int width, int height, double renderScale, boolean imageTarget) throws Exception { String configuredFormat = watermark.getBarcodeFormat(); if (!"QR_CODE".equalsIgnoreCase(configuredFormat) && !"CODE_128".equalsIgnoreCase(configuredFormat)) { throw new DataException("不支持的水印条码格式,请选择 Code 128 或二维码"); } BarcodeFormat format = BarcodeFormat.valueOf(configuredFormat.toUpperCase(Locale.ROOT)); String content = value(watermark.getContent(), " "); if (imageTarget && format == BarcodeFormat.CODE_128 && content.length() > MAX_IMAGE_CODE128_CHARACTERS) { throw new DataException("图片 Code 128 水印内容过长"); } if (!Boolean.TRUE.equals(watermark.getShowBarcodeText())) { return barcodeSymbol(content, format, width, height); } String label = watermark.getBarcodeText() == null ? content : watermark.getBarcodeText(); Font font = chooseFont(watermark.getFontFamily(), label, BARCODE_TEXT_FONT_SIZE, renderScale); int textPadding = Math.max(1, (int) Math.round(BARCODE_TEXT_PADDING_PX * Math.max(0.01D, renderScale))); BufferedImage metricsImage = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); Graphics2D metricsGraphics = metricsImage.createGraphics(); int textHeight; try { metricsGraphics.setFont(font); textHeight = metricsGraphics.getFontMetrics().getHeight() + textPadding * 2; } finally { metricsGraphics.dispose(); } int symbolHeight = Math.max(1, height - textHeight); BufferedImage symbol = barcodeSymbol(content, format, width, symbolHeight); int imageWidth = Math.max(width, symbol.getWidth()); int imageHeight = Math.max(height, symbol.getHeight() + textHeight); BufferedImage image = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_ARGB); Graphics2D graphics = image.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); graphics.drawImage(symbol, (imageWidth - symbol.getWidth()) / 2, 0, null); graphics.setFont(font); graphics.setColor(Color.BLACK); FontMetrics metrics = graphics.getFontMetrics(); int textX = Math.max(textPadding, (imageWidth - metrics.stringWidth(label)) / 2); int baseline = imageHeight - textPadding - metrics.getDescent(); graphics.drawString(label, textX, baseline); } finally { graphics.dispose(); } return image; } private BufferedImage barcodeSymbol(String content, BarcodeFormat format, int width, int height) throws Exception { Map hints = new LinkedHashMap<>(); hints.put(EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name()); BitMatrix matrix = new MultiFormatWriter().encode(content, format, width, height, hints); long pixels = (long) matrix.getWidth() * matrix.getHeight(); if (matrix.getWidth() > MAX_RASTER_MARK_DIMENSION || matrix.getHeight() > MAX_RASTER_MARK_DIMENSION || pixels > MAX_RASTER_MARK_PIXELS) { throw new DataException("条码处理量超过系统安全上限"); } BufferedImage image = new BufferedImage(matrix.getWidth(), matrix.getHeight(), BufferedImage.TYPE_INT_ARGB); for (int y = 0; y < matrix.getHeight(); y++) { for (int x = 0; x < matrix.getWidth(); x++) { if (matrix.get(x, y)) { image.setRGB(x, y, Color.BLACK.getRGB()); } } } return image; } private BufferedImage loadImage(String imageUrl) throws Exception { if (imageUrl == null || imageUrl.trim().isEmpty()) { throw new DataException("图片水印缺少图片文件"); } String value = imageUrl.trim(); if (value.startsWith("data:image/")) { int comma = value.indexOf(','); if (comma < 0 || !value.substring(0, comma).endsWith(";base64")) { throw new DataException("图片水印的 data URI 格式不正确"); } String encoded = value.substring(comma + 1); long maxEncodedLength = ((MAX_IMAGE_MARK_SOURCE_BYTES + 2L) / 3L) * 4L + 4L; if (encoded.length() > maxEncodedLength) { throw new DataException("图片水印素材超过处理上限(10 MB)"); } try { byte[] bytes = java.util.Base64.getDecoder().decode(encoded); if (bytes.length > MAX_IMAGE_MARK_SOURCE_BYTES) { throw new DataException("图片水印素材超过处理上限(10 MB)"); } return readImage(bytes); } catch (IllegalArgumentException exception) { throw new DataException("图片水印的 data URI 格式不正确"); } } FileSource source = parseFileSource(value); final BufferedImage[] image = new BufferedImage[1]; commonFileStorage.download(source.type, source.fileName, input -> { try { image[0] = readImage(input); } catch (Exception exception) { throw new ImageReadException(exception); } }); if (image[0] == null) { throw new DataException("图片水印文件内容为空"); } return image[0]; } private BufferedImage readImage(InputStream input) throws Exception { DmsBoundedByteArrayOutputStream bytes = new DmsBoundedByteArrayOutputStream( MAX_IMAGE_MARK_SOURCE_BYTES, "图片水印素材超过处理上限(10 MB)"); input.transferTo(bytes); return readImage(bytes.toByteArrayAndRelease()); } private BufferedImage readImage(byte[] source) throws Exception { try (DmsByteArrayImageInputStream input = new DmsByteArrayImageInputStream(source)) { java.util.Iterator readers = ImageIO.getImageReaders(input); if (!readers.hasNext()) { throw new DataException("图片水印仅支持可识别的图片格式"); } ImageReader reader = readers.next(); try { reader.setInput(input, false, false); int width = reader.getWidth(0); int height = reader.getHeight(0); long pixels = (long) width * height; if (width <= 0 || height <= 0 || width > MAX_IMAGE_MARK_DIMENSION || height > MAX_IMAGE_MARK_DIMENSION || pixels > MAX_IMAGE_MARK_PIXELS) { throw new DataException("图片水印素材尺寸超过处理上限"); } BufferedImage image = reader.read(0); if (image == null) { throw new DataException("图片水印仅支持可识别的图片格式"); } return image; } finally { reader.dispose(); } } } private FileSource parseFileSource(String value) { try { URI uri = URI.create(value); String path = uri.getRawPath(); int index = path == null ? -1 : path.indexOf(FILE_URL_PREFIX); if (index < 0) { throw new DataException("图片水印必须来自 file-common"); } String[] parts = path.substring(index + FILE_URL_PREFIX.length()).split("/", -1); if (parts.length != 2) { throw new DataException("图片水印文件地址格式不正确"); } String type = decode(parts[0]); String fileName = decode(parts[1]); validateSegment(type); validateSegment(fileName); return new FileSource(type, fileName); } catch (DataException exception) { throw exception; } catch (IllegalArgumentException exception) { throw new DataException("图片水印文件地址格式不正确"); } } private String decode(String value) { return UriUtils.decode(value, StandardCharsets.UTF_8); } private void validateSegment(String value) { if (value == null || value.trim().isEmpty() || value.contains("/") || value.contains("\\") || ".".equals(value) || "..".equals(value)) { throw new DataException("图片水印文件地址不合法"); } } private BufferedImage fitImage(BufferedImage source, int width, int height) { BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); double scale = Math.min((double) width / source.getWidth(), (double) height / source.getHeight()); int targetWidth = Math.max(1, (int) Math.round(source.getWidth() * scale)); int targetHeight = Math.max(1, (int) Math.round(source.getHeight() * scale)); int x = (width - targetWidth) / 2; int y = (height - targetHeight) / 2; Graphics2D graphics = result.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); graphics.drawImage(source, x, y, targetWidth, targetHeight, null); } finally { graphics.dispose(); } return result; } private DmsWatermarkPositionSnapshot closestPosition(PageSize page, List positions) { if (positions == null || positions.isEmpty()) { throw new DataException("水印配置缺少页面位置"); } DmsWatermarkPositionSnapshot best = null; double bestDistance = Double.MAX_VALUE; for (DmsWatermarkPositionSnapshot candidate : positions) { double width = positive(candidate.getPaperWidthMm(), 210D); double height = positive(candidate.getPaperHeightMm(), 297D); double direct = Math.abs(width - page.width) + Math.abs(height - page.height); double rotated = Math.abs(height - page.width) + Math.abs(width - page.height); double distance = Math.min(direct, rotated); if (distance < bestDistance) { best = candidate; bestDistance = distance; } } return best; } private double coordinate(PageSize page, DmsWatermarkPositionSnapshot position, boolean x) { boolean landscape = page.width > page.height; BigDecimal value; if (landscape) { value = x ? position.getLandscapeXMm() : position.getLandscapeYMm(); } else { value = x ? position.getPortraitXMm() : position.getPortraitYMm(); } return value == null ? 0D : value.doubleValue(); } private List tiledPoints(PageSize page, double originX, double originY, double width, double height, double spacingX, double spacingY) { double stepX = Math.max(1D, width + spacingX); double stepY = Math.max(1D, height + spacingY); double startX = originX; double startY = originY; while (startX - stepX > -width) { startX -= stepX; } while (startY - stepY > -height) { startY -= stepY; } List points = new ArrayList<>(); for (double y = startY; y < page.height && points.size() < MAX_TILED_MARKS; y += stepY) { for (double x = startX; x < page.width && points.size() < MAX_TILED_MARKS; x += stepX) { points.add(new Point(x, y)); } } return points; } private List points(PageSize page, DmsWatermarkSnapshot watermark) { DmsWatermarkPositionSnapshot position = closestPosition(page, watermark.getPositions()); double x = coordinate(page, position, true); double y = coordinate(page, position, false); double width = positive(watermark.getMarkWidthMm(), 80D); double height = positive(watermark.getMarkHeightMm(), 20D); if ("TILED".equalsIgnoreCase(watermark.getLayoutMode())) { return tiledPoints(page, x, y, width, height, nonNegative(watermark.getRepeatSpacingXMm(), 20D), nonNegative(watermark.getRepeatSpacingYMm(), 20D)); } return Collections.singletonList(new Point( clamp(x, 0D, Math.max(0D, page.width - width)), clamp(y, 0D, Math.max(0D, page.height - height)))); } private double clamp(double value, double minimum, double maximum) { return Math.max(minimum, Math.min(maximum, value)); } // Page numbers must remain Word fields: a repeated header image cannot vary per page. private P pageFieldAnchor(DmsWatermarkSnapshot watermark, Point point, int id) throws Exception { double width = positive(watermark.getMarkWidthMm(), 80D); double height = positive(watermark.getMarkHeightMm(), 20D); org.w3c.dom.Document xml = newXml("w:p"); Element paragraph = xml.getDocumentElement(); add(add(paragraph, "w:pPr"), "w:spacing", "w:before", "0", "w:after", "0", "w:line", "1", "w:lineRule", "exact"); Element drawing = add(add(paragraph, "w:r"), "w:drawing"); Element anchor = add(drawing, "wp:anchor", "distT", "0", "distB", "0", "distL", "0", "distR", "0", "simplePos", "0", "relativeHeight", "1", "behindDoc", "1", "locked", "1", "layoutInCell", "1", "allowOverlap", "1"); add(anchor, "wp:simplePos", "x", "0", "y", "0"); add(add(anchor, "wp:positionH", "relativeFrom", "page"), "wp:posOffset").setTextContent(emu(point.x)); add(add(anchor, "wp:positionV", "relativeFrom", "page"), "wp:posOffset").setTextContent(emu(point.y)); add(anchor, "wp:extent", "cx", emu(width), "cy", emu(height)); add(anchor, "wp:effectExtent", "l", "0", "t", "0", "r", "0", "b", "0"); add(anchor, "wp:wrapNone"); add(anchor, "wp:docPr", "id", Integer.toString(id), "name", "DMS_PAGE_WATERMARK_" + id); add(add(anchor, "wp:cNvGraphicFramePr"), "a:graphicFrameLocks", "noSelect", "1", "noMove", "1", "noResize", "1"); Element shape = add(add(add(anchor, "a:graphic"), "a:graphicData", "uri", WPS), "wps:wsp"); add(shape, "wps:cNvSpPr", "txBox", "1"); Element properties = add(shape, "wps:spPr"); Element transform = add(properties, "a:xfrm", "rot", Long.toString(Math.floorMod(value(watermark.getRotationAngle(), 0), 360) * 60000L)); add(transform, "a:off", "x", "0", "y", "0"); add(transform, "a:ext", "cx", emu(width), "cy", emu(height)); add(add(properties, "a:prstGeom", "prst", "rect"), "a:avLst"); add(properties, "a:noFill"); add(add(properties, "a:ln"), "a:noFill"); Element content = add(add(shape, "wps:txbx"), "w:txbxContent"); for (String line : value(watermark.getContent(), "").split("\\R", -1)) { Element p = add(content, "w:p"); Element pPr = add(p, "w:pPr"); add(pPr, "w:spacing", "w:before", "0", "w:after", "0"); add(pPr, "w:jc", "w:val", horizontalAlignment(watermark).toLowerCase(Locale.ROOT)); Matcher matcher = PAGE_VARIABLE.matcher(line); int start = 0; while (matcher.find()) { fieldText(p, line.substring(start, matcher.start()), watermark); add(fieldRun(p, watermark), "w:fldChar", "w:fldCharType", "begin", "w:dirty", "true"); Element instruction = add(fieldRun(p, watermark), "w:instrText"); instruction.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve"); instruction.setTextContent("CurrPage".equals(matcher.group(1)) ? " PAGE " : " NUMPAGES "); add(fieldRun(p, watermark), "w:fldChar", "w:fldCharType", "separate"); fieldText(p, "1", watermark); add(fieldRun(p, watermark), "w:fldChar", "w:fldCharType", "end"); start = matcher.end(); } fieldText(p, line.substring(start), watermark); } // OnlyOffice sizes a no-wrap header textbox from its first-page field values. // When PAGE gains a digit (9 -> 10), that cached width clips the end of NUMPAGES. // Lay out text within the configured box on every page instead. String verticalAnchor = switch (verticalAlignment(watermark)) { case "MIDDLE" -> "ctr"; case "BOTTOM" -> "b"; default -> "t"; }; Element body = add(shape, "wps:bodyPr", "rot", "0", "vert", "horz", "wrap", "square", "lIns", "0", "tIns", "0", "rIns", "0", "bIns", "0", "anchor", verticalAnchor); add(body, "a:noAutofit"); return (P) unmarshal(xml); } private void fieldText(Element paragraph, String value, DmsWatermarkSnapshot watermark) { if (!value.isEmpty()) { Element text = add(fieldRun(paragraph, watermark), "w:t"); text.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve"); text.setTextContent(value); } } private Element fieldRun(Element paragraph, DmsWatermarkSnapshot watermark) { Element run = add(paragraph, "w:r"); Element properties = add(run, "w:rPr"); String font = chooseFont(watermark.getFontFamily(), value(watermark.getContent(), ""), value(watermark.getFontSize(), 24)).getFamily(Locale.ENGLISH); add(properties, "w:rFonts", "w:ascii", font, "w:hAnsi", font, "w:eastAsia", font, "w:cs", font); Color color = parseColor(watermark.getFontColor()); String rgb = String.format(Locale.ROOT, "%06X", color.getRGB() & 0xFFFFFF); add(properties, "w:color", "w:val", rgb); String size = Integer.toString(Math.max(1, value(watermark.getFontSize(), 24)) * 2); add(properties, "w:sz", "w:val", size); add(properties, "w:szCs", "w:val", size); Element fill = add(add(add(properties, "w14:textFill"), "w14:solidFill"), "w14:srgbClr", "w14:val", rgb); double opacity = Math.max(0, Math.min(100, value(watermark.getOpacity(), 100))) * color.getAlpha() / 255D; int transparency = (int) Math.round((100D - opacity) * 1000D); add(fill, "w14:alpha", "w14:val", Integer.toString(transparency)); return run; } private P anchor(String relationshipId, PageSize page, int id) throws Exception { org.w3c.dom.Document xml = newXml("w:p"); Element paragraph = xml.getDocumentElement(); add(add(paragraph, "w:pPr"), "w:spacing", "w:before", "0", "w:after", "0", "w:line", "1", "w:lineRule", "exact"); Element drawing = add(add(paragraph, "w:r"), "w:drawing"); Element anchor = add(drawing, "wp:anchor", "distT", "0", "distB", "0", "distL", "0", "distR", "0", "simplePos", "0", "relativeHeight", "0", "behindDoc", "1", "locked", "1", "layoutInCell", "1", "allowOverlap", "1"); add(anchor, "wp:simplePos", "x", "0", "y", "0"); add(add(anchor, "wp:positionH", "relativeFrom", "page"), "wp:posOffset").setTextContent("0"); add(add(anchor, "wp:positionV", "relativeFrom", "page"), "wp:posOffset").setTextContent("0"); add(anchor, "wp:extent", "cx", emu(page.width), "cy", emu(page.height)); add(anchor, "wp:effectExtent", "l", "0", "t", "0", "r", "0", "b", "0"); add(anchor, "wp:wrapNone"); add(anchor, "wp:docPr", "id", Integer.toString(id), "name", "DMS_WATERMARK_" + id, "descr", "Dynamic DMS preview watermark"); add(add(anchor, "wp:cNvGraphicFramePr"), "a:graphicFrameLocks", "noChangeAspect", "1", "noSelect", "1", "noMove", "1", "noResize", "1"); Element data = add(add(anchor, "a:graphic"), "a:graphicData", "uri", PIC); Element picture = add(data, "pic:pic"); Element nonVisual = add(picture, "pic:nvPicPr"); add(nonVisual, "pic:cNvPr", "id", Integer.toString(id), "name", "dms-watermark.png"); add(nonVisual, "pic:cNvPicPr"); Element fill = add(picture, "pic:blipFill"); add(fill, "a:blip", "r:embed", relationshipId); add(add(fill, "a:stretch"), "a:fillRect"); Element properties = add(picture, "pic:spPr"); Element transform = add(properties, "a:xfrm"); add(transform, "a:off", "x", "0", "y", "0"); add(transform, "a:ext", "cx", emu(page.width), "cy", emu(page.height)); add(add(properties, "a:prstGeom", "prst", "rect"), "a:avLst"); return (P) unmarshal(xml); } private org.w3c.dom.Document newXml(String root) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); org.w3c.dom.Document xml = factory.newDocumentBuilder().newDocument(); Element element = xml.createElementNS(namespace(root), root); xml.appendChild(element); for (Map.Entry namespace : NAMESPACES.entrySet()) { element.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:" + namespace.getKey(), namespace.getValue()); } return xml; } private Element add(Element parent, String name, String... attributes) { Element result = parent.getOwnerDocument().createElementNS(namespace(name), name); for (int i = 0; i < attributes.length; i += 2) { String key = attributes[i]; if (key.contains(":")) { result.setAttributeNS(namespace(key), key, attributes[i + 1]); } else { result.setAttribute(key, attributes[i + 1]); } } parent.appendChild(result); return result; } private Object unmarshal(org.w3c.dom.Document xml) throws Exception { StringWriter writer = new StringWriter(); TransformerFactory.newInstance().newTransformer().transform(new DOMSource(xml), new StreamResult(writer)); return XmlUtils.unwrap(XmlUtils.unmarshalString(writer.toString())); } private String namespace(String qualifiedName) { return NAMESPACES.get(qualifiedName.substring(0, qualifiedName.indexOf(':'))); } private int px(double millimeters) { return Math.max(1, (int) Math.round(millimeters * DPI / 25.4D)); } private int pixelOffset(double millimeters) { return (int) Math.round(millimeters * DPI / 25.4D); } private String emu(double millimeters) { return Long.toString(Math.round(millimeters * 36000D)); } private double positive(BigDecimal value, double fallback) { return value == null || value.signum() <= 0 ? fallback : value.doubleValue(); } private double nonNegative(BigDecimal value, double fallback) { return value == null || value.signum() < 0 ? fallback : value.doubleValue(); } private int value(Integer value, int fallback) { return value == null ? fallback : value; } private String value(String value, String fallback) { return value == null ? fallback : value; } private String message(Exception exception) { Throwable cause = exception; while (cause.getCause() != null) { cause = cause.getCause(); } return cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage(); } private static final class PageSize { private final double width; private final double height; private PageSize(double width, double height) { this.width = width; this.height = height; } } private static final class Point { private final double x; private final double y; private Point(double x, double y) { this.x = x; this.y = y; } } private static final class RasterSize { private final int width; private final int height; private RasterSize(int width, int height) { this.width = width; this.height = height; } } private static final class ImageReference { private final double width; private final double height; private final double x; private final double y; private ImageReference(double width, double height, double x, double y) { this.width = width; this.height = height; this.x = x; this.y = y; } } private static final class PositionedImage { private final byte[] png; private final double width; private final double height; private final double x; private final double y; private PositionedImage(byte[] png, double width, double height, double x, double y) { this.png = png; this.width = width; this.height = height; this.x = x; this.y = y; } } private static final class FileSource { private final String type; private final String fileName; private FileSource(String type, String fileName) { this.type = type; this.fileName = fileName; } } private static final class ImageReadException extends RuntimeException { private ImageReadException(Throwable cause) { super(cause); } } }