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<DmsWatermarkSnapshot> watermarks) {
|
return apply(source, fileName, null, watermarks);
|
}
|
|
public byte[] apply(byte[] source, String fileName, String mimeType,
|
List<DmsWatermarkSnapshot> watermarks) {
|
return loadAndApply(() -> source, fileName, mimeType, watermarks);
|
}
|
|
byte[] loadAndApply(SourceReader sourceReader, String fileName, String mimeType,
|
List<DmsWatermarkSnapshot> watermarks) {
|
if (sourceReader == null) {
|
throw new DataException("图片源文件读取器未配置");
|
}
|
if (watermarks == null || watermarks.isEmpty()) {
|
return readSource(sourceReader);
|
}
|
List<DmsWatermarkSnapshot> 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<ImageReader> 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<DmsWatermarkSnapshot> applicableWatermarks(List<DmsWatermarkSnapshot> watermarks) {
|
List<DmsWatermarkSnapshot> 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<ImageWriter> 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;
|
}
|
}
|