package jnpf.fileStorage.dms; import jnpf.dmsEntity.preview.model.DmsWatermarkSnapshot; import jnpf.exception.DataException; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import javax.imageio.IIOImage; import javax.imageio.ImageReader; import javax.imageio.ImageWriteParam; import javax.imageio.ImageWriter; import javax.imageio.stream.FileImageOutputStream; import java.awt.Color; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; @Service public class DmsImageWatermarkService { static final long MAX_SOURCE_BYTES = 50L * 1024L * 1024L; static final int MAX_WATERMARKS = 16; private static final int MAX_DIMENSION = 16_384; private static final long MAX_PIXELS = 16_000_000L; private static final int MAX_OUTPUT_BYTES = 100 * 1024 * 1024; private static final long DEFAULT_RENDER_PERMIT_TIMEOUT_MILLIS = 5_000L; private final DmsDocxWatermarkService rasterizer; private final Semaphore renderPermit = new Semaphore(1, true); private final long renderPermitTimeoutMillis; @Autowired public DmsImageWatermarkService(DmsDocxWatermarkService rasterizer) { this(rasterizer, DEFAULT_RENDER_PERMIT_TIMEOUT_MILLIS); } DmsImageWatermarkService(DmsDocxWatermarkService rasterizer, long renderPermitTimeoutMillis) { this.rasterizer = rasterizer; this.renderPermitTimeoutMillis = Math.max(0L, renderPermitTimeoutMillis); } public byte[] apply(byte[] source, String fileName, List watermarks) { return apply(source, fileName, null, watermarks); } public byte[] apply(byte[] source, String fileName, String mimeType, List watermarks) { return loadAndApply(() -> source, fileName, mimeType, watermarks); } byte[] loadAndApply(SourceReader sourceReader, String fileName, String mimeType, List watermarks) { if (sourceReader == null) { throw new DataException("图片源文件读取器未配置"); } if (watermarks == null || watermarks.isEmpty()) { return readSource(sourceReader); } List applicable = applicableWatermarks(watermarks); String expectedFormat = outputFormat(fileName, mimeType); boolean acquired = false; try { acquired = renderPermit.tryAcquire(renderPermitTimeoutMillis, TimeUnit.MILLISECONDS); if (!acquired) { throw new DataException("图片水印处理繁忙,请稍后重试"); } byte[] source = sourceReader.read(); if (source == null || source.length == 0) { throw new DataException("图片内容为空,不能生成水印"); } if (source.length > MAX_SOURCE_BYTES) { throw new DataException("图片超过动态水印处理上限(50 MB)"); } BufferedImage composed = readAndCompose(source, expectedFormat); rasterizer.applyImageWatermarks(composed, applicable); return write(composed, expectedFormat); } catch (DataException exception) { throw exception; } catch (InterruptedException exception) { Thread.currentThread().interrupt(); throw new DataException("图片水印处理已取消"); } catch (Exception exception) { throw new DataException("生成图片水印失败:" + message(exception)); } finally { if (acquired) { renderPermit.release(); } } } private byte[] readSource(SourceReader sourceReader) { try { return sourceReader.read(); } catch (DataException exception) { throw exception; } catch (Exception exception) { throw new DataException("读取图片内容失败:" + message(exception)); } } private BufferedImage readAndCompose(byte[] source, String expectedFormat) throws Exception { int orientation = "jpeg".equals(expectedFormat) ? DmsJpegExifOrientation.readOrientation(source) : 1; try (DmsByteArrayImageInputStream input = new DmsByteArrayImageInputStream(source)) { Iterator readers = javax.imageio.ImageIO.getImageReaders(input); if (!readers.hasNext()) { throw new DataException("图片内容无法识别,不能生成水印"); } ImageReader reader = readers.next(); try { reader.setInput(input, false, false); String actualFormat = normalizeFormat(reader.getFormatName()); if (!expectedFormat.equals(actualFormat)) { throw new DataException("图片文件扩展名与实际格式不一致,不能生成水印"); } int width = reader.getWidth(0); int height = reader.getHeight(0); validateDimensions(width, height); if ("gif".equals(actualFormat) && hasMultipleGifFrames(source)) { throw new DataException("暂不支持动态 GIF 水印,请先转换为 PNG 或 JPEG"); } BufferedImage original = reader.read(0); if (original == null) { throw new DataException("图片内容无法解码,不能生成水印"); } return compose(DmsJpegExifOrientation.normalize(original, orientation), expectedFormat); } finally { reader.dispose(); } } } private List applicableWatermarks(List watermarks) { List result = new ArrayList<>(); if (watermarks == null) { return result; } if (watermarks.size() > MAX_WATERMARKS) { throw new DataException("单张图片最多支持 " + MAX_WATERMARKS + " 个水印"); } for (DmsWatermarkSnapshot watermark : watermarks) { if (watermark == null) { throw new DataException("图片水印配置无效"); } if (watermark.getRenderPages() != null && (watermark.getRenderPages().isEmpty() || watermark.getRenderPages().stream().anyMatch(page -> page == null || page != 1))) { throw new DataException("图片只有第 1 页,不支持其他指定页水印"); } DmsWatermarkSnapshot imageMark = new DmsWatermarkSnapshot(); BeanUtils.copyProperties(watermark, imageMark); if ("TEXT".equalsIgnoreCase(value(imageMark.getWatermarkType(), "TEXT"))) { imageMark.setContent(value(imageMark.getContent(), "") .replace("{/CurrPage}", "1") .replace("{/TotalPage}", "1")); } result.add(imageMark); } return result; } private BufferedImage compose(BufferedImage original, String format) { boolean alpha = "png".equals(format) || "gif".equals(format); BufferedImage result = new BufferedImage(original.getWidth(), original.getHeight(), alpha ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB); Graphics2D graphics = result.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); if (!alpha) { graphics.setColor(Color.WHITE); graphics.fillRect(0, 0, result.getWidth(), result.getHeight()); } graphics.drawImage(original, 0, 0, null); } finally { graphics.dispose(); } return result; } private byte[] write(BufferedImage image, String format) throws Exception { Iterator writers = javax.imageio.ImageIO.getImageWritersByFormatName(format); if (!writers.hasNext()) { throw new DataException("当前运行环境不能生成 " + format.toUpperCase(Locale.ROOT) + " 水印图片"); } ImageWriter writer = writers.next(); Path outputFile = null; try { outputFile = Files.createTempFile("dms-watermark-", "." + format); try (FileImageOutputStream output = new FileImageOutputStream(outputFile.toFile())) { writer.setOutput(output); ImageWriteParam parameters = writer.getDefaultWriteParam(); if ("jpeg".equals(format) && parameters.canWriteCompressed()) { parameters.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); parameters.setCompressionQuality(0.92F); } writer.write(null, new IIOImage(image, null, null), parameters); output.flush(); } if (Files.size(outputFile) > MAX_OUTPUT_BYTES) { throw new DataException("水印图片输出超过处理上限(100 MB)"); } return Files.readAllBytes(outputFile); } finally { writer.dispose(); if (outputFile != null) { try { Files.deleteIfExists(outputFile); } catch (IOException ignored) { // Best-effort cleanup must not mask a successful render or its original failure. } } } } private void validateDimensions(int width, int height) { long pixels = (long) width * height; if (width <= 0 || height <= 0 || width > MAX_DIMENSION || height > MAX_DIMENSION || pixels > MAX_PIXELS) { throw new DataException("图片尺寸超过动态水印处理上限"); } } private boolean hasMultipleGifFrames(byte[] source) { if (source.length < 13 || source[0] != 'G' || source[1] != 'I' || source[2] != 'F') { return false; } int cursor = 13; if ((source[10] & 0x80) != 0) { cursor += 3 << ((source[10] & 0x07) + 1); } if (cursor > source.length) return true; int frames = 0; while (cursor < source.length) { int blockType = source[cursor++] & 0xFF; if (blockType == 0x3B) { return false; } if (blockType == 0x00) { continue; } if (blockType == 0x21) { if (cursor >= source.length) return true; cursor++; cursor = skipGifSubBlocks(source, cursor); if (cursor < 0) return true; continue; } if (blockType != 0x2C || cursor + 9 > source.length) { return true; } if (++frames > 1) { return true; } int packed = source[cursor + 8] & 0xFF; cursor += 9; if ((packed & 0x80) != 0) { cursor += 3 << ((packed & 0x07) + 1); } if (cursor >= source.length) return true; cursor++; cursor = skipGifSubBlocks(source, cursor); if (cursor < 0) return true; } return true; } private int skipGifSubBlocks(byte[] source, int cursor) { while (cursor < source.length) { int length = source[cursor++] & 0xFF; if (length == 0) return cursor; if (cursor > source.length - length) return -1; cursor += length; } return -1; } private static String outputFormat(String fileName, String mimeType) { String extension = extension(fileName); if ("jpg".equals(extension) || "jpeg".equals(extension)) { return "jpeg"; } if ("png".equals(extension) || "bmp".equals(extension) || "gif".equals(extension)) { return extension; } if (!extension.isEmpty()) { throw new DataException("该图片格式不支持动态水印"); } String normalizedMime = normalizeMime(mimeType); if ("image/jpeg".equals(normalizedMime) || "image/jpg".equals(normalizedMime)) { return "jpeg"; } if ("image/png".equals(normalizedMime)) { return "png"; } if ("image/bmp".equals(normalizedMime) || "image/x-ms-bmp".equals(normalizedMime)) { return "bmp"; } if ("image/gif".equals(normalizedMime)) { return "gif"; } throw new DataException("该图片格式不支持动态水印"); } static String contentType(String fileName) { return contentType(fileName, null); } static String contentType(String fileName, String mimeType) { String format = outputFormat(fileName, mimeType); return "jpeg".equals(format) ? "image/jpeg" : "image/" + format; } private static String extension(String fileName) { int dot = fileName == null ? -1 : fileName.lastIndexOf('.'); return dot < 0 || dot == fileName.length() - 1 ? "" : fileName.substring(dot + 1).trim().toLowerCase(Locale.ROOT); } private static String normalizeMime(String mimeType) { String normalized = mimeType == null ? "" : mimeType.trim().toLowerCase(Locale.ROOT); int parameters = normalized.indexOf(';'); return parameters < 0 ? normalized : normalized.substring(0, parameters).trim(); } private String normalizeFormat(String format) { String normalized = format == null ? "" : format.toLowerCase(Locale.ROOT); return "jpg".equals(normalized) ? "jpeg" : normalized; } 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(); } @FunctionalInterface interface SourceReader { byte[] read() throws Exception; } }