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<String, String> 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<String, String> 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<DmsWatermarkSnapshot> 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<HeaderPart, PageSize> targets = effectiveHeaders(word);
|
List<DmsWatermarkSnapshot> staticMarks = new ArrayList<>();
|
List<DmsWatermarkSnapshot> 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<HeaderPart, PageSize> 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<PageWatermarkRequest.Overlay> pageOverlays(byte[] source, List<DmsWatermarkSnapshot> marks) throws Exception {
|
WordprocessingMLPackage word = WordprocessingMLPackage.load(new ByteArrayInputStream(source));
|
Map<String, PageSize> 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<PageWatermarkRequest.Overlay> 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<PageWatermarkRequest.Variant> 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<HeaderPart, PageSize> effectiveHeaders(WordprocessingMLPackage word) throws Exception {
|
Map<HeaderPart, PageSize> result = new IdentityHashMap<>();
|
List<SectionWrapper> 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<SectPr> 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<HeaderPart> 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<HeaderPart> 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<DmsWatermarkSnapshot> 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<DmsWatermarkSnapshot> 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<DmsWatermarkSnapshot> 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<Point> 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<Point> 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<DmsWatermarkPositionSnapshot> 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<Point> 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<EncodeHintType, Object> 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<ImageReader> 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<DmsWatermarkPositionSnapshot> 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<Point> 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<Point> 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<Point> 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<String, String> 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);
|
}
|
}
|
}
|