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 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 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 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(); } }