刘光辉
4 小时以前 06f314545ac3514d7ce28939920866ae8188d31c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
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;
    }
}