package jnpf.fileStorage.dms;
|
|
import com.google.zxing.BinaryBitmap;
|
import com.google.zxing.MultiFormatReader;
|
import com.google.zxing.RGBLuminanceSource;
|
import com.google.zxing.common.HybridBinarizer;
|
import jnpf.dmsEntity.preview.model.DmsWatermarkPositionSnapshot;
|
import jnpf.dmsEntity.preview.model.DmsWatermarkSnapshot;
|
import jnpf.exception.DataException;
|
import org.junit.Test;
|
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
|
import javax.imageio.IIOImage;
|
import javax.imageio.ImageIO;
|
import javax.imageio.ImageWriter;
|
import javax.imageio.stream.MemoryCacheImageOutputStream;
|
import java.awt.Color;
|
import java.awt.Graphics2D;
|
import java.awt.image.BufferedImage;
|
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayOutputStream;
|
import java.io.InputStream;
|
import java.math.BigDecimal;
|
import java.util.Arrays;
|
import java.util.List;
|
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.Executors;
|
import java.util.concurrent.Future;
|
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.zip.CRC32;
|
|
import static org.junit.Assert.assertArrayEquals;
|
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertNotEquals;
|
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertTrue;
|
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.mock;
|
|
public class DmsImageWatermarkServiceTest {
|
private final DmsImageWatermarkService service = new DmsImageWatermarkService(
|
new DmsDocxWatermarkService(mock(DmsCommonFileStorage.class)));
|
|
@Test
|
public void springSelectsProductionConstructor() {
|
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
context.registerBean(DmsDocxWatermarkService.class,
|
() -> new DmsDocxWatermarkService(mock(DmsCommonFileStorage.class)));
|
context.register(DmsImageWatermarkService.class);
|
context.refresh();
|
|
assertTrue(context.containsBean("dmsImageWatermarkService"));
|
}
|
}
|
|
@Test
|
public void appliesTextWatermarkToPngWithoutMutatingSource() throws Exception {
|
byte[] source = image("png", 640, 360);
|
byte[] original = source.clone();
|
|
byte[] result = service.apply(source, "inspection.PNG", List.of(watermark()));
|
|
assertArrayEquals(original, source);
|
assertNotEquals(Arrays.hashCode(source), Arrays.hashCode(result));
|
BufferedImage rendered = ImageIO.read(new ByteArrayInputStream(result));
|
assertEquals(640, rendered.getWidth());
|
assertEquals(360, rendered.getHeight());
|
assertTrue(countDarkPixels(rendered) > 20);
|
}
|
|
@Test
|
public void keepsJpegEncodingAndDimensions() throws Exception {
|
byte[] source = image("jpeg", 480, 320);
|
|
byte[] result = service.apply(source, "inspection.jpg", List.of(watermark()));
|
|
assertEquals(0xFF, result[0] & 0xFF);
|
assertEquals(0xD8, result[1] & 0xFF);
|
BufferedImage rendered = ImageIO.read(new ByteArrayInputStream(result));
|
assertEquals(480, rendered.getWidth());
|
assertEquals(320, rendered.getHeight());
|
assertTrue(countDarkPixels(rendered) > 20);
|
}
|
|
@Test
|
public void normalizesJpegExifOrientationBeforePlacingWatermark() throws Exception {
|
byte[] source = withExifOrientation(image("jpeg", 600, 400), 6);
|
|
byte[] result = service.apply(source, "phone-photo.jpg", List.of(watermark()));
|
|
BufferedImage rendered = ImageIO.read(new ByteArrayInputStream(result));
|
assertEquals(400, rendered.getWidth());
|
assertEquals(600, rendered.getHeight());
|
assertTrue(countDarkPixels(rendered) > 20);
|
}
|
|
@Test
|
public void keepsBmpAndStaticGifFormats() throws Exception {
|
for (String format : List.of("bmp", "gif")) {
|
byte[] result = service.apply(image(format, 320, 240),
|
"inspection." + format, List.of(watermark()));
|
|
assertEquals("bmp".equals(format) ? 'B' : 'G', result[0]);
|
BufferedImage rendered = ImageIO.read(new ByteArrayInputStream(result));
|
assertEquals(320, rendered.getWidth());
|
assertEquals(240, rendered.getHeight());
|
assertTrue(countDarkPixels(rendered) > 20);
|
}
|
}
|
|
@Test
|
public void highResolutionQrWatermarkRemainsDecodable() throws Exception {
|
DmsWatermarkSnapshot qr = watermark();
|
qr.setWatermarkType("BARCODE");
|
qr.setBarcodeFormat("QR_CODE");
|
qr.setContent("DMS-IMAGE-WATERMARK-001");
|
qr.setRotationAngle(0);
|
qr.setMarkWidthMm(new BigDecimal("80"));
|
qr.setMarkHeightMm(new BigDecimal("80"));
|
|
byte[] result = service.apply(image("png", 2400, 1600),
|
"inspection.png", List.of(qr));
|
|
assertEquals(qr.getContent(), decode(ImageIO.read(new ByteArrayInputStream(result))));
|
}
|
|
@Test
|
public void scalesCode128IntoUserConfiguredBoxWithoutRejectingItsIntrinsicSize() throws Exception {
|
DmsWatermarkSnapshot barcode = watermark();
|
barcode.setWatermarkType("BARCODE");
|
barcode.setBarcodeFormat("CODE_128");
|
barcode.setContent("PIC-SMP100-01000");
|
barcode.setRotationAngle(0);
|
barcode.setMarkWidthMm(new BigDecimal("12"));
|
barcode.setMarkHeightMm(new BigDecimal("5"));
|
|
byte[] result = service.apply(image("png", 640, 360),
|
"inspection.png", List.of(barcode));
|
|
BufferedImage rendered = ImageIO.read(new ByteArrayInputStream(result));
|
assertEquals(640, rendered.getWidth());
|
assertEquals(360, rendered.getHeight());
|
assertTrue(countDarkPixels(rendered) > 0);
|
}
|
|
@Test
|
public void rejectsLongCode128BeforeAllocatingExpandedRaster() throws Exception {
|
DmsWatermarkSnapshot barcode = watermark();
|
barcode.setWatermarkType("BARCODE");
|
barcode.setBarcodeFormat("CODE_128");
|
barcode.setContent("A".repeat(2_000));
|
barcode.setMarkWidthMm(BigDecimal.ONE);
|
|
DataException error = assertThrows(DataException.class, () -> service.apply(
|
image("png", 640, 360), "inspection.png", List.of(barcode)));
|
|
assertTrue(error.getMessage().contains("Code 128 水印内容过长"));
|
}
|
|
@Test
|
public void usesImageMimeTypeWhenFileNameHasNoExtension() throws Exception {
|
byte[] source = image("png", 320, 240);
|
|
byte[] result = service.apply(source, "inspection", "image/png", List.of(watermark()));
|
|
assertEquals(0x89, result[0] & 0xFF);
|
assertEquals("image/png", DmsImageWatermarkService.contentType("inspection", "image/png"));
|
assertTrue(countDarkPixels(ImageIO.read(new ByteArrayInputStream(result))) > 20);
|
}
|
|
@Test
|
public void rejectsUnsupportedImageFormatInsteadOfReturningOriginalBytes() {
|
byte[] source = {1, 2, 3};
|
|
DataException error = assertThrows(DataException.class,
|
() -> service.apply(source, "inspection.webp", "image/webp", List.of(watermark())));
|
|
assertTrue(error.getMessage().contains("不支持动态水印"));
|
}
|
|
@Test
|
public void boundedBufferRejectsBytesPastItsLimit() {
|
DmsBoundedByteArrayOutputStream output = new DmsBoundedByteArrayOutputStream(4, "limit");
|
output.write(new byte[]{1, 2, 3, 4}, 0, 4);
|
|
DataException error = assertThrows(DataException.class, () -> output.write(5));
|
|
assertEquals("limit", error.getMessage());
|
assertEquals(4, output.size());
|
}
|
|
@Test
|
public void boundedBufferReleasesItsBackingStorageAfterExtraction() {
|
DmsBoundedByteArrayOutputStream output = new DmsBoundedByteArrayOutputStream(8, "limit");
|
output.write(new byte[]{1, 2, 3, 4}, 0, 4);
|
|
assertArrayEquals(new byte[]{1, 2, 3, 4}, output.toByteArrayAndRelease());
|
assertEquals(0, output.size());
|
}
|
|
@Test
|
public void rejectsBusyRequestBeforeReadingItsSource() throws Exception {
|
DmsDocxWatermarkService rasterizer = mock(DmsDocxWatermarkService.class);
|
DmsImageWatermarkService guarded = new DmsImageWatermarkService(rasterizer, 10L);
|
CountDownLatch rendering = new CountDownLatch(1);
|
CountDownLatch release = new CountDownLatch(1);
|
doAnswer(invocation -> {
|
rendering.countDown();
|
assertTrue(release.await(5, TimeUnit.SECONDS));
|
return null;
|
}).when(rasterizer).applyImageWatermarks(any(), any());
|
byte[] source = image("png", 320, 240);
|
ExecutorService executor = Executors.newSingleThreadExecutor();
|
try {
|
Future<byte[]> first = executor.submit(() -> guarded.loadAndApply(
|
() -> source, "first.png", "image/png", List.of(watermark())));
|
assertTrue(rendering.await(5, TimeUnit.SECONDS));
|
AtomicBoolean secondSourceRead = new AtomicBoolean(false);
|
|
DataException error = assertThrows(DataException.class, () -> guarded.loadAndApply(() -> {
|
secondSourceRead.set(true);
|
return source;
|
}, "second.png", "image/png", List.of(watermark())));
|
|
assertTrue(error.getMessage().contains("处理繁忙"));
|
assertFalse(secondSourceRead.get());
|
release.countDown();
|
assertTrue(first.get(5, TimeUnit.SECONDS).length > 0);
|
} finally {
|
release.countDown();
|
executor.shutdownNow();
|
}
|
}
|
|
@Test
|
public void rejectsTooManyWatermarksBeforeReadingSource() {
|
AtomicBoolean sourceRead = new AtomicBoolean(false);
|
List<DmsWatermarkSnapshot> watermarks = java.util.stream.IntStream
|
.rangeClosed(1, DmsImageWatermarkService.MAX_WATERMARKS + 1)
|
.mapToObj(index -> watermark())
|
.toList();
|
|
DataException error = assertThrows(DataException.class, () -> service.loadAndApply(() -> {
|
sourceRead.set(true);
|
return new byte[]{1};
|
}, "inspection.png", "image/png", watermarks));
|
|
assertTrue(error.getMessage().contains("最多支持"));
|
assertFalse(sourceRead.get());
|
}
|
|
@Test
|
public void allowsWatermarkLargerThanImageCanvas() throws Exception {
|
DmsWatermarkSnapshot oversized = watermark();
|
oversized.setMarkWidthMm(new BigDecimal("1000"));
|
oversized.setMarkHeightMm(new BigDecimal("1000"));
|
|
byte[] result = service.apply(image("png", 640, 360),
|
"inspection.png", List.of(oversized));
|
|
BufferedImage rendered = ImageIO.read(new ByteArrayInputStream(result));
|
assertEquals(640, rendered.getWidth());
|
assertEquals(360, rendered.getHeight());
|
}
|
|
@Test
|
public void doesNotMoveOutOfBoundsWatermarkBackOntoImage() throws Exception {
|
DmsWatermarkSnapshot outside = watermark();
|
outside.setRotationAngle(0);
|
outside.setMarkWidthMm(new BigDecimal("20"));
|
outside.setMarkHeightMm(new BigDecimal("20"));
|
DmsWatermarkPositionSnapshot position = outside.getPositions().get(0);
|
position.setLandscapeXMm(new BigDecimal("400"));
|
position.setLandscapeYMm(new BigDecimal("50"));
|
|
byte[] result = service.apply(image("png", 640, 360),
|
"inspection.png", List.of(outside));
|
|
assertEquals(0, countDarkPixels(ImageIO.read(new ByteArrayInputStream(result))));
|
}
|
|
@Test
|
public void rejectsOversizedImageWatermarkAssetBeforeDecodingPixels() throws Exception {
|
DmsCommonFileStorage commonStorage = mock(DmsCommonFileStorage.class);
|
doAnswer(invocation -> {
|
java.util.function.Consumer<InputStream> consumer = invocation.getArgument(2);
|
consumer.accept(new ByteArrayInputStream(oversizedPng()));
|
return null;
|
}).when(commonStorage).download(any(), any(), any());
|
DmsImageWatermarkService guarded = new DmsImageWatermarkService(
|
new DmsDocxWatermarkService(commonStorage));
|
DmsWatermarkSnapshot imageMark = watermark();
|
imageMark.setWatermarkType("IMAGE");
|
imageMark.setImageUrl("http://localhost/api/file/Image/watermark/logo.png");
|
|
DataException error = assertThrows(DataException.class, () -> guarded.apply(
|
image("png", 640, 360), "inspection.png", List.of(imageMark)));
|
|
assertTrue(error.getMessage(), error.getMessage().contains("素材尺寸超过处理上限"));
|
}
|
|
@Test
|
public void rejectsWatermarksAssignedToNonexistentImagePages() throws Exception {
|
byte[] source = image("png", 320, 240);
|
DmsWatermarkSnapshot watermark = watermark();
|
watermark.setRenderPages(List.of(2, 3));
|
|
DataException error = assertThrows(DataException.class,
|
() -> service.apply(source, "inspection.png", List.of(watermark)));
|
|
assertTrue(error.getMessage().contains("只有第 1 页"));
|
}
|
|
@Test
|
public void rejectsAnimatedGifInsteadOfDroppingFrames() throws Exception {
|
DataException error = assertThrows(DataException.class,
|
() -> service.apply(animatedGif(), "animation.gif", List.of(watermark())));
|
|
assertTrue(error.getMessage().contains("动态 GIF"));
|
}
|
|
@Test
|
public void rejectsContentWhoseMagicDoesNotMatchItsExtension() throws Exception {
|
DataException error = assertThrows(DataException.class,
|
() -> service.apply(image("png", 100, 100), "renamed.jpg", List.of(watermark())));
|
|
assertTrue(error.getMessage().contains("扩展名与实际格式不一致"));
|
}
|
|
private DmsWatermarkSnapshot watermark() {
|
DmsWatermarkSnapshot watermark = new DmsWatermarkSnapshot();
|
watermark.setWatermarkType("TEXT");
|
watermark.setContent("CONFIDENTIAL");
|
watermark.setFontFamily("SansSerif");
|
watermark.setFontColor("#000000");
|
watermark.setFontSize(18);
|
watermark.setRotationAngle(-20);
|
watermark.setOpacity(100);
|
watermark.setLayoutMode("POSITION");
|
watermark.setMarkWidthMm(new BigDecimal("80"));
|
watermark.setMarkHeightMm(new BigDecimal("20"));
|
DmsWatermarkPositionSnapshot position = new DmsWatermarkPositionSnapshot();
|
position.setPaperWidthMm(new BigDecimal("210"));
|
position.setPaperHeightMm(new BigDecimal("297"));
|
position.setPortraitXMm(new BigDecimal("65"));
|
position.setPortraitYMm(new BigDecimal("138"));
|
position.setLandscapeXMm(new BigDecimal("108"));
|
position.setLandscapeYMm(new BigDecimal("95"));
|
watermark.setPositions(List.of(position));
|
return watermark;
|
}
|
|
private byte[] image(String format, int width, int height) throws Exception {
|
BufferedImage image = new BufferedImage(width, height,
|
"png".equals(format) ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB);
|
Graphics2D graphics = image.createGraphics();
|
try {
|
graphics.setColor(Color.WHITE);
|
graphics.fillRect(0, 0, width, height);
|
} finally {
|
graphics.dispose();
|
}
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
assertTrue(ImageIO.write(image, format, output));
|
return output.toByteArray();
|
}
|
|
private byte[] animatedGif() throws Exception {
|
ImageWriter writer = ImageIO.getImageWritersByFormatName("gif").next();
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
try (MemoryCacheImageOutputStream output = new MemoryCacheImageOutputStream(bytes)) {
|
writer.setOutput(output);
|
writer.prepareWriteSequence(null);
|
writer.writeToSequence(new IIOImage(frame(Color.WHITE), null, null), writer.getDefaultWriteParam());
|
writer.writeToSequence(new IIOImage(frame(Color.LIGHT_GRAY), null, null), writer.getDefaultWriteParam());
|
writer.endWriteSequence();
|
output.flush();
|
} finally {
|
writer.dispose();
|
}
|
return bytes.toByteArray();
|
}
|
|
private byte[] withExifOrientation(byte[] jpeg, int orientation) {
|
byte[] payload = new byte[32];
|
payload[0] = 'E';
|
payload[1] = 'x';
|
payload[2] = 'i';
|
payload[3] = 'f';
|
int tiff = 6;
|
payload[tiff] = 'I';
|
payload[tiff + 1] = 'I';
|
putShort(payload, tiff + 2, 42);
|
putInt(payload, tiff + 4, 8);
|
putShort(payload, tiff + 8, 1);
|
int entry = tiff + 10;
|
putShort(payload, entry, 0x0112);
|
putShort(payload, entry + 2, 3);
|
putInt(payload, entry + 4, 1);
|
putShort(payload, entry + 8, orientation);
|
|
int segmentLength = payload.length + 2;
|
byte[] result = new byte[jpeg.length + payload.length + 4];
|
result[0] = jpeg[0];
|
result[1] = jpeg[1];
|
result[2] = (byte) 0xFF;
|
result[3] = (byte) 0xE1;
|
result[4] = (byte) (segmentLength >>> 8);
|
result[5] = (byte) segmentLength;
|
System.arraycopy(payload, 0, result, 6, payload.length);
|
System.arraycopy(jpeg, 2, result, 6 + payload.length, jpeg.length - 2);
|
return result;
|
}
|
|
private byte[] oversizedPng() throws Exception {
|
byte[] png = image("png", 1, 1);
|
putBigEndianInt(png, 16, 10_000);
|
putBigEndianInt(png, 20, 10_000);
|
CRC32 crc = new CRC32();
|
crc.update(png, 12, 17);
|
putBigEndianInt(png, 29, (int) crc.getValue());
|
return png;
|
}
|
|
private void putBigEndianInt(byte[] data, int offset, int value) {
|
data[offset] = (byte) (value >>> 24);
|
data[offset + 1] = (byte) (value >>> 16);
|
data[offset + 2] = (byte) (value >>> 8);
|
data[offset + 3] = (byte) value;
|
}
|
|
private void putShort(byte[] data, int offset, int value) {
|
data[offset] = (byte) value;
|
data[offset + 1] = (byte) (value >>> 8);
|
}
|
|
private void putInt(byte[] data, int offset, int value) {
|
data[offset] = (byte) value;
|
data[offset + 1] = (byte) (value >>> 8);
|
data[offset + 2] = (byte) (value >>> 16);
|
data[offset + 3] = (byte) (value >>> 24);
|
}
|
|
private BufferedImage frame(Color color) {
|
BufferedImage image = new BufferedImage(80, 60, BufferedImage.TYPE_INT_RGB);
|
Graphics2D graphics = image.createGraphics();
|
try {
|
graphics.setColor(color);
|
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
|
} finally {
|
graphics.dispose();
|
}
|
return image;
|
}
|
|
private long countDarkPixels(BufferedImage image) {
|
long count = 0;
|
for (int y = 0; y < image.getHeight(); y++) {
|
for (int x = 0; x < image.getWidth(); x++) {
|
Color color = new Color(image.getRGB(x, y), true);
|
if (color.getAlpha() > 0 && color.getRed() < 100
|
&& color.getGreen() < 100 && color.getBlue() < 100) {
|
count++;
|
}
|
}
|
}
|
return count;
|
}
|
|
private String decode(BufferedImage image) throws Exception {
|
int width = image.getWidth();
|
int height = image.getHeight();
|
int[] pixels = image.getRGB(0, 0, width, height, null, 0, width);
|
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(
|
new RGBLuminanceSource(width, height, pixels)));
|
return new MultiFormatReader().decode(bitmap).getText();
|
}
|
}
|