From db142af139583d0dcefb53dd6ba011160e2c5daf Mon Sep 17 00:00:00 2001 From: antonarhipov Date: Wed, 29 Oct 2025 13:03:31 +0200 Subject: [PATCH 01/20] Watermark functionality enhancements for #3421 - Grid layout and random positioning of watermarks per page - Random orientation - Per watermark font randomization of color and size - Text watermark per-letter randomization of font, color, size, and orientation - Sharing - Random mirroring of image watermarks - Add `WatermarkRandomizer` utility and enhance watermark request handling with new validations and randomization features --- .../common/util/WatermarkRandomizer.java | 315 ++++++++ .../common/util/WatermarkRandomizerTest.java | 603 +++++++++++++++ .../api/security/WatermarkController.java | 692 ++++++++++++++--- .../api/security/AddWatermarkRequest.java | 153 ++++ .../templates/security/add-watermark.html | 543 ++++++++++++- .../WatermarkControllerIntegrationTest.java | 730 ++++++++++++++++++ .../api/security/WatermarkValidationTest.java | 711 +++++++++++++++++ 7 files changed, 3595 insertions(+), 152 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/util/WatermarkRandomizer.java create mode 100644 app/common/src/test/java/stirling/software/common/util/WatermarkRandomizerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerIntegrationTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkValidationTest.java diff --git a/app/common/src/main/java/stirling/software/common/util/WatermarkRandomizer.java b/app/common/src/main/java/stirling/software/common/util/WatermarkRandomizer.java new file mode 100644 index 000000000..8bc93f1ba --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/WatermarkRandomizer.java @@ -0,0 +1,315 @@ +package stirling.software.common.util; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * Utility class for generating randomized watermark attributes with deterministic (seedable) + * randomness. Supports position, rotation, mirroring, font selection, size, color, and shading. + */ +public class WatermarkRandomizer { + + private final Random random; + + /** + * Creates a WatermarkRandomizer with an optional seed for deterministic randomness. + * + * @param seed Optional seed value; if null, uses non-deterministic randomness + */ + public WatermarkRandomizer(Long seed) { + this.random = (seed != null) ? new Random(seed) : new Random(); + } + + /** + * Generates a random position within the given bounds and margins. + * + * @param pageWidth Width of the page + * @param pageHeight Height of the page + * @param watermarkWidth Width of the watermark + * @param watermarkHeight Height of the watermark + * @param margin Minimum margin from page edges + * @param boundsX Optional bounding box X coordinate (null for full page) + * @param boundsY Optional bounding box Y coordinate (null for full page) + * @param boundsWidth Optional bounding box width (null for full page) + * @param boundsHeight Optional bounding box height (null for full page) + * @return Array with [x, y] coordinates + */ + public float[] generateRandomPosition( + float pageWidth, + float pageHeight, + float watermarkWidth, + float watermarkHeight, + float margin, + Float boundsX, + Float boundsY, + Float boundsWidth, + Float boundsHeight) { + + // Determine effective bounds + float effectiveX = (boundsX != null) ? boundsX : margin; + float effectiveY = (boundsY != null) ? boundsY : margin; + float effectiveWidth = (boundsWidth != null) ? boundsWidth : (pageWidth - 2 * margin); + float effectiveHeight = (boundsHeight != null) ? boundsHeight : (pageHeight - 2 * margin); + + // Calculate available space + float maxX = Math.max(effectiveX, effectiveX + effectiveWidth - watermarkWidth); + float maxY = Math.max(effectiveY, effectiveY + effectiveHeight - watermarkHeight); + float minX = effectiveX; + float minY = effectiveY; + + // Generate random position within bounds + float x = minX + random.nextFloat() * Math.max(0, maxX - minX); + float y = minY + random.nextFloat() * Math.max(0, maxY - minY); + + return new float[] {x, y}; + } + + /** + * Generates a list of fixed grid positions based on spacers. + * + * @param pageWidth Width of the page + * @param pageHeight Height of the page + * @param watermarkWidth Width of the watermark (includes spacing) + * @param watermarkHeight Height of the watermark (includes spacing) + * @param widthSpacer Horizontal spacing between watermarks + * @param heightSpacer Vertical spacing between watermarks + * @param count Maximum number of watermarks (0 for unlimited grid) + * @return List of [x, y] coordinate arrays + */ + public List generateGridPositions( + float pageWidth, + float pageHeight, + float watermarkWidth, + float watermarkHeight, + int widthSpacer, + int heightSpacer, + int count) { + + List positions = new ArrayList<>(); + + // Calculate how many rows and columns can fit on the page based on spacers + int maxRows = (int) (pageHeight / (watermarkHeight + heightSpacer) + 1); + int maxCols = (int) (pageWidth / (watermarkWidth + widthSpacer) + 1); + + if (count == 0) { + // Unlimited grid: fill entire page using spacer-based grid + for (int i = 0; i <= maxRows; i++) { + for (int j = 0; j <= maxCols; j++) { + float x = j * (watermarkWidth + widthSpacer); + float y = i * (watermarkHeight + heightSpacer); + positions.add(new float[] {x, y}); + } + } + } else { + // Limited count: distribute evenly across the page using spacer-based grid + // Calculate optimal distribution + int cols = + Math.min((int) Math.ceil(Math.sqrt(count * pageWidth / pageHeight)), maxCols); + int rows = Math.min((int) Math.ceil((double) count / cols), maxRows); + + // Calculate step sizes to distribute watermarks evenly across available grid + int colStep = Math.max(1, maxCols / cols); + int rowStep = Math.max(1, maxRows / rows); + + int generated = 0; + for (int i = 0; i < maxRows && generated < count; i += rowStep) { + for (int j = 0; j < maxCols && generated < count; j += colStep) { + float x = j * (watermarkWidth + widthSpacer); + float y = i * (watermarkHeight + heightSpacer); + positions.add(new float[] {x, y}); + generated++; + } + } + } + + return positions; + } + + /** + * Generates a random rotation angle within the specified range. + * + * @param rotationMin Minimum rotation angle in degrees + * @param rotationMax Maximum rotation angle in degrees + * @return Random rotation angle in degrees + */ + public float generateRandomRotation(float rotationMin, float rotationMax) { + if (rotationMin == rotationMax) { + return rotationMin; + } + return rotationMin + random.nextFloat() * (rotationMax - rotationMin); + } + + /** + * Determines whether to mirror based on probability. + * + * @param probability Probability of mirroring (0.0 to 1.0) + * @return true if should mirror, false otherwise + */ + public boolean shouldMirror(float probability) { + return random.nextFloat() < probability; + } + + /** + * Selects a random font from the available font list. + * + * @param availableFonts List of available font names + * @return Random font name from the list + */ + public String selectRandomFont(List availableFonts) { + if (availableFonts == null || availableFonts.isEmpty()) { + return "default"; + } + return availableFonts.get(random.nextInt(availableFonts.size())); + } + + /** + * Generates a random font size within the specified range. + * + * @param fontSizeMin Minimum font size + * @param fontSizeMax Maximum font size + * @return Random font size + */ + public float generateRandomFontSize(float fontSizeMin, float fontSizeMax) { + if (fontSizeMin == fontSizeMax) { + return fontSizeMin; + } + return fontSizeMin + random.nextFloat() * (fontSizeMax - fontSizeMin); + } + + /** + * Generates a random color from a predefined palette or full spectrum. + * + * @param usePalette If true, uses a predefined palette; otherwise generates random RGB + * @return Random Color object + */ + public Color generateRandomColor(boolean usePalette) { + if (usePalette) { + // Predefined palette of common colors + Color[] palette = { + Color.BLACK, + Color.DARK_GRAY, + Color.GRAY, + Color.LIGHT_GRAY, + Color.RED, + Color.BLUE, + Color.GREEN, + Color.ORANGE, + Color.MAGENTA, + Color.CYAN, + Color.PINK, + Color.YELLOW + }; + return palette[random.nextInt(palette.length)]; + } else { + // Generate random RGB color + return new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)); + } + } + + /** + * Selects a random shading style from available options. + * + * @param availableShadings List of available shading styles + * @return Random shading style + */ + public String selectRandomShading(List availableShadings) { + if (availableShadings == null || availableShadings.isEmpty()) { + return "none"; + } + return availableShadings.get(random.nextInt(availableShadings.size())); + } + + /** + * Generates a random rotation for per-letter orientation within a safe range. + * + * @param maxRotation Maximum rotation angle in degrees (applied as +/- range) + * @return Random rotation angle in degrees + */ + public float generatePerLetterRotation(float maxRotation) { + return -maxRotation + random.nextFloat() * (2 * maxRotation); + } + + /** + * Generates a random color from a limited palette. + * + * @param colorCount Number of colors to select from (1-12 from predefined palette) + * @return Random Color object from the limited palette + */ + public Color generateRandomColorFromPalette(int colorCount) { + // Predefined palette of common colors + Color[] fullPalette = { + Color.BLACK, + Color.DARK_GRAY, + Color.GRAY, + Color.LIGHT_GRAY, + Color.RED, + Color.BLUE, + Color.GREEN, + Color.ORANGE, + Color.MAGENTA, + Color.CYAN, + Color.PINK, + Color.YELLOW + }; + + // Limit to requested count + int actualCount = Math.min(colorCount, fullPalette.length); + actualCount = Math.max(1, actualCount); // At least 1 + + return fullPalette[random.nextInt(actualCount)]; + } + + /** + * Selects a random font from a limited list of available fonts. + * + * @param fontCount Number of fonts to select from + * @return Random font name + */ + public String selectRandomFontFromCount(int fontCount) { + // Predefined list of common PDF fonts + String[] availableFonts = { + "Helvetica", + "Times-Roman", + "Courier", + "Helvetica-Bold", + "Times-Bold", + "Courier-Bold", + "Helvetica-Oblique", + "Times-Italic", + "Courier-Oblique", + "Symbol", + "ZapfDingbats" + }; + + // Limit to requested count + int actualCount = Math.min(fontCount, availableFonts.length); + actualCount = Math.max(1, actualCount); // At least 1 + + return availableFonts[random.nextInt(actualCount)]; + } + + /** + * Generates a random rotation for per-letter orientation within specified range. + * + * @param minRotation Minimum rotation angle in degrees + * @param maxRotation Maximum rotation angle in degrees + * @return Random rotation angle in degrees + */ + public float generatePerLetterRotationInRange(float minRotation, float maxRotation) { + if (minRotation == maxRotation) { + return minRotation; + } + return minRotation + random.nextFloat() * (maxRotation - minRotation); + } + + /** + * Gets the underlying Random instance for advanced use cases. + * + * @return The Random instance + */ + public Random getRandom() { + return random; + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/WatermarkRandomizerTest.java b/app/common/src/test/java/stirling/software/common/util/WatermarkRandomizerTest.java new file mode 100644 index 000000000..f711c7913 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/WatermarkRandomizerTest.java @@ -0,0 +1,603 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.awt.Color; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("WatermarkRandomizer Unit Tests") +class WatermarkRandomizerTest { + + private static final long TEST_SEED = 12345L; + + @Nested + @DisplayName("Position Randomization Tests") + class PositionRandomizationTests { + + @Test + @DisplayName("Should generate deterministic random position with seed") + void testGenerateRandomPositionWithSeed() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + + float[] pos1 = + randomizer1.generateRandomPosition( + 800f, 600f, 100f, 50f, 10f, null, null, null, null); + float[] pos2 = + randomizer2.generateRandomPosition( + 800f, 600f, 100f, 50f, 10f, null, null, null, null); + + assertArrayEquals(pos1, pos2, "Same seed should produce same position"); + } + + @Test + @DisplayName("Should respect margin constraints") + void testGenerateRandomPositionWithMargin() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float margin = 20f; + float pageWidth = 800f; + float pageHeight = 600f; + float watermarkWidth = 100f; + float watermarkHeight = 50f; + + for (int i = 0; i < 10; i++) { + float[] pos = + randomizer.generateRandomPosition( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + margin, + null, + null, + null, + null); + + assertTrue(pos[0] >= margin, "X position should respect margin"); + assertTrue(pos[1] >= margin, "Y position should respect margin"); + assertTrue( + pos[0] <= pageWidth - margin - watermarkWidth, + "X position should not exceed page width minus margin"); + assertTrue( + pos[1] <= pageHeight - margin - watermarkHeight, + "Y position should not exceed page height minus margin"); + } + } + + @Test + @DisplayName("Should respect custom bounds") + void testGenerateRandomPositionWithBounds() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float boundsX = 100f; + float boundsY = 100f; + float boundsWidth = 400f; + float boundsHeight = 300f; + float watermarkWidth = 50f; + float watermarkHeight = 30f; + + for (int i = 0; i < 10; i++) { + float[] pos = + randomizer.generateRandomPosition( + 800f, + 600f, + watermarkWidth, + watermarkHeight, + 10f, + boundsX, + boundsY, + boundsWidth, + boundsHeight); + + assertTrue(pos[0] >= boundsX, "X position should be within bounds"); + assertTrue(pos[1] >= boundsY, "Y position should be within bounds"); + assertTrue( + pos[0] <= boundsX + boundsWidth - watermarkWidth, + "X position should not exceed bounds"); + assertTrue( + pos[1] <= boundsY + boundsHeight - watermarkHeight, + "Y position should not exceed bounds"); + } + } + + @Test + @DisplayName("Should generate grid positions correctly") + void testGenerateGridPositions() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 800f; + float pageHeight = 600f; + float watermarkWidth = 100f; + float watermarkHeight = 100f; + int widthSpacer = 50; + int heightSpacer = 50; + int count = 5; + + List positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertNotNull(positions, "Positions should not be null"); + assertEquals(count, positions.size(), "Should generate requested count of positions"); + + // Verify positions are within page bounds + for (float[] pos : positions) { + assertTrue(pos[0] >= 0 && pos[0] <= pageWidth, "X position within page width"); + assertTrue(pos[1] >= 0 && pos[1] <= pageHeight, "Y position within page height"); + } + } + + @Test + @DisplayName("Should generate unlimited grid when count is 0") + void testGenerateGridPositionsUnlimited() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 400f; + float pageHeight = 300f; + float watermarkWidth = 100f; + float watermarkHeight = 100f; + + List positions = + randomizer.generateGridPositions( + pageWidth, pageHeight, watermarkWidth, watermarkHeight, 50, 50, 0); + + assertNotNull(positions, "Positions should not be null"); + assertTrue(positions.size() > 0, "Should generate at least one position"); + } + } + + @Nested + @DisplayName("Rotation Randomization Tests") + class RotationRandomizationTests { + + @Test + @DisplayName("Should generate deterministic rotation with seed") + void testGenerateRandomRotationWithSeed() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + + float rotation1 = randomizer1.generateRandomRotation(0f, 360f); + float rotation2 = randomizer2.generateRandomRotation(0f, 360f); + + assertEquals(rotation1, rotation2, "Same seed should produce same rotation"); + } + + @Test + @DisplayName("Should generate rotation within range") + void testGenerateRandomRotationInRange() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float minRotation = -45f; + float maxRotation = 45f; + + for (int i = 0; i < 20; i++) { + float rotation = randomizer.generateRandomRotation(minRotation, maxRotation); + assertTrue( + rotation >= minRotation && rotation <= maxRotation, + "Rotation should be within specified range"); + } + } + + @Test + @DisplayName("Should return fixed rotation when min equals max") + void testGenerateRandomRotationFixed() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float fixedRotation = 30f; + + float rotation = randomizer.generateRandomRotation(fixedRotation, fixedRotation); + + assertEquals( + fixedRotation, rotation, "Should return fixed rotation when min equals max"); + } + + @Test + @DisplayName("Should generate per-letter rotation within symmetric range") + void testGeneratePerLetterRotation() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float maxRotation = 30f; + + for (int i = 0; i < 20; i++) { + float rotation = randomizer.generatePerLetterRotation(maxRotation); + assertTrue( + rotation >= -maxRotation && rotation <= maxRotation, + "Per-letter rotation should be within +/- maxRotation"); + } + } + + @Test + @DisplayName("Should generate per-letter rotation in specified range") + void testGeneratePerLetterRotationInRange() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float minRotation = -15f; + float maxRotation = 45f; + + for (int i = 0; i < 20; i++) { + float rotation = + randomizer.generatePerLetterRotationInRange(minRotation, maxRotation); + assertTrue( + rotation >= minRotation && rotation <= maxRotation, + "Per-letter rotation should be within specified range"); + } + } + + @Test + @DisplayName("Should return fixed per-letter rotation when min equals max") + void testGeneratePerLetterRotationInRangeFixed() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float fixedRotation = 20f; + + float rotation = + randomizer.generatePerLetterRotationInRange(fixedRotation, fixedRotation); + + assertEquals( + fixedRotation, rotation, "Should return fixed rotation when min equals max"); + } + } + + @Nested + @DisplayName("Mirroring Randomization Tests") + class MirroringRandomizationTests { + + @Test + @DisplayName("Should generate deterministic mirroring decision with seed") + void testShouldMirrorWithSeed() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + + boolean mirror1 = randomizer1.shouldMirror(0.5f); + boolean mirror2 = randomizer2.shouldMirror(0.5f); + + assertEquals(mirror1, mirror2, "Same seed should produce same mirroring decision"); + } + + @Test + @DisplayName("Should always mirror with probability 1.0") + void testShouldMirrorAlways() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + + for (int i = 0; i < 10; i++) { + assertTrue( + randomizer.shouldMirror(1.0f), "Should always mirror with probability 1.0"); + } + } + + @Test + @DisplayName("Should never mirror with probability 0.0") + void testShouldMirrorNever() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + + for (int i = 0; i < 10; i++) { + assertFalse( + randomizer.shouldMirror(0.0f), "Should never mirror with probability 0.0"); + } + } + + @Test + @DisplayName("Should mirror approximately 50% of the time with probability 0.5") + void testShouldMirrorProbability() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + int mirrorCount = 0; + int iterations = 100; + + for (int i = 0; i < iterations; i++) { + if (randomizer.shouldMirror(0.5f)) { + mirrorCount++; + } + } + + // Allow some variance (30-70%) + assertTrue( + mirrorCount >= 30 && mirrorCount <= 70, + "Should mirror approximately 50% of the time"); + } + } + + @Nested + @DisplayName("Font Randomization Tests") + class FontRandomizationTests { + + @Test + @DisplayName("Should select deterministic font with seed") + void testSelectRandomFontWithSeed() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + List fonts = Arrays.asList("Arial", "Times", "Courier"); + + String font1 = randomizer1.selectRandomFont(fonts); + String font2 = randomizer2.selectRandomFont(fonts); + + assertEquals(font1, font2, "Same seed should produce same font selection"); + } + + @Test + @DisplayName("Should select font from provided list") + void testSelectRandomFontFromList() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + List fonts = Arrays.asList("Arial", "Times", "Courier"); + + for (int i = 0; i < 10; i++) { + String font = randomizer.selectRandomFont(fonts); + assertTrue(fonts.contains(font), "Selected font should be from the provided list"); + } + } + + @Test + @DisplayName("Should return default when font list is empty") + void testSelectRandomFontEmpty() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + List fonts = Arrays.asList(); + + String font = randomizer.selectRandomFont(fonts); + + assertEquals("default", font, "Should return 'default' when list is empty"); + } + + @Test + @DisplayName("Should return default when font list is null") + void testSelectRandomFontNull() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + + String font = randomizer.selectRandomFont(null); + + assertEquals("default", font, "Should return 'default' when list is null"); + } + + @Test + @DisplayName("Should select font from count") + void testSelectRandomFontFromCount() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + int fontCount = 5; + + for (int i = 0; i < 10; i++) { + String font = randomizer.selectRandomFontFromCount(fontCount); + assertNotNull(font, "Selected font should not be null"); + assertFalse(font.isEmpty(), "Selected font should not be empty"); + } + } + + @Test + @DisplayName("Should handle font count exceeding available fonts") + void testSelectRandomFontFromCountExceedsAvailable() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + int fontCount = 100; // More than available + + String font = randomizer.selectRandomFontFromCount(fontCount); + + assertNotNull(font, "Should still return a valid font"); + } + } + + @Nested + @DisplayName("Font Size Randomization Tests") + class FontSizeRandomizationTests { + + @Test + @DisplayName("Should generate deterministic font size with seed") + void testGenerateRandomFontSizeWithSeed() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + + float size1 = randomizer1.generateRandomFontSize(10f, 50f); + float size2 = randomizer2.generateRandomFontSize(10f, 50f); + + assertEquals(size1, size2, "Same seed should produce same font size"); + } + + @Test + @DisplayName("Should generate font size within range") + void testGenerateRandomFontSizeInRange() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float minSize = 10f; + float maxSize = 50f; + + for (int i = 0; i < 20; i++) { + float size = randomizer.generateRandomFontSize(minSize, maxSize); + assertTrue( + size >= minSize && size <= maxSize, + "Font size should be within specified range"); + } + } + + @Test + @DisplayName("Should return fixed font size when min equals max") + void testGenerateRandomFontSizeFixed() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float fixedSize = 30f; + + float size = randomizer.generateRandomFontSize(fixedSize, fixedSize); + + assertEquals(fixedSize, size, "Should return fixed size when min equals max"); + } + } + + @Nested + @DisplayName("Color Randomization Tests") + class ColorRandomizationTests { + + @Test + @DisplayName("Should generate deterministic color with seed") + void testGenerateRandomColorWithSeed() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + + Color color1 = randomizer1.generateRandomColor(false); + Color color2 = randomizer2.generateRandomColor(false); + + assertEquals(color1, color2, "Same seed should produce same color"); + } + + @Test + @DisplayName("Should generate color from palette") + void testGenerateRandomColorFromPalette() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + Color[] expectedPalette = { + Color.BLACK, + Color.DARK_GRAY, + Color.GRAY, + Color.LIGHT_GRAY, + Color.RED, + Color.BLUE, + Color.GREEN, + Color.ORANGE, + Color.MAGENTA, + Color.CYAN, + Color.PINK, + Color.YELLOW + }; + + for (int i = 0; i < 20; i++) { + Color color = randomizer.generateRandomColor(true); + boolean inPalette = false; + for (Color paletteColor : expectedPalette) { + if (color.equals(paletteColor)) { + inPalette = true; + break; + } + } + assertTrue(inPalette, "Color should be from predefined palette"); + } + } + + @Test + @DisplayName("Should generate RGB color when not using palette") + void testGenerateRandomColorRGB() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + + for (int i = 0; i < 10; i++) { + Color color = randomizer.generateRandomColor(false); + assertNotNull(color, "Color should not be null"); + assertTrue(color.getRed() >= 0 && color.getRed() <= 255, "Red component valid"); + assertTrue( + color.getGreen() >= 0 && color.getGreen() <= 255, "Green component valid"); + assertTrue(color.getBlue() >= 0 && color.getBlue() <= 255, "Blue component valid"); + } + } + + @Test + @DisplayName("Should generate color from limited palette") + void testGenerateRandomColorFromPaletteWithCount() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + int colorCount = 4; + + for (int i = 0; i < 10; i++) { + Color color = randomizer.generateRandomColorFromPalette(colorCount); + assertNotNull(color, "Color should not be null"); + } + } + + @Test + @DisplayName("Should handle color count exceeding palette size") + void testGenerateRandomColorFromPaletteExceedsSize() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + int colorCount = 100; // More than palette size + + Color color = randomizer.generateRandomColorFromPalette(colorCount); + + assertNotNull(color, "Should still return a valid color"); + } + + @Test + @DisplayName("Should handle color count of 1") + void testGenerateRandomColorFromPaletteCountOne() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + + Color color = randomizer.generateRandomColorFromPalette(1); + + assertEquals(Color.BLACK, color, "Should return first color in palette"); + } + } + + @Nested + @DisplayName("Shading Randomization Tests") + class ShadingRandomizationTests { + + @Test + @DisplayName("Should select deterministic shading with seed") + void testSelectRandomShadingWithSeed() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + List shadings = Arrays.asList("none", "light", "dark"); + + String shading1 = randomizer1.selectRandomShading(shadings); + String shading2 = randomizer2.selectRandomShading(shadings); + + assertEquals(shading1, shading2, "Same seed should produce same shading selection"); + } + + @Test + @DisplayName("Should select shading from provided list") + void testSelectRandomShadingFromList() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + List shadings = Arrays.asList("none", "light", "dark"); + + for (int i = 0; i < 10; i++) { + String shading = randomizer.selectRandomShading(shadings); + assertTrue( + shadings.contains(shading), + "Selected shading should be from the provided list"); + } + } + + @Test + @DisplayName("Should return 'none' when shading list is empty") + void testSelectRandomShadingEmpty() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + List shadings = Arrays.asList(); + + String shading = randomizer.selectRandomShading(shadings); + + assertEquals("none", shading, "Should return 'none' when list is empty"); + } + + @Test + @DisplayName("Should return 'none' when shading list is null") + void testSelectRandomShadingNull() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + + String shading = randomizer.selectRandomShading(null); + + assertEquals("none", shading, "Should return 'none' when list is null"); + } + } + + @Nested + @DisplayName("Random Instance Tests") + class RandomInstanceTests { + + @Test + @DisplayName("Should return non-null Random instance") + void testGetRandom() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + + assertNotNull(randomizer.getRandom(), "Random instance should not be null"); + } + + @Test + @DisplayName("Should use seeded Random for deterministic behavior") + void testSeededRandomBehavior() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + + int value1 = randomizer1.getRandom().nextInt(100); + int value2 = randomizer2.getRandom().nextInt(100); + + assertEquals(value1, value2, "Seeded Random should produce same values"); + } + + @Test + @DisplayName("Should use non-deterministic Random when seed is null") + void testNonSeededRandomBehavior() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(null); + + assertNotNull( + randomizer.getRandom(), "Random instance should not be null even without seed"); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index 23546f93d..e4b1fc2f5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -38,14 +38,17 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.security.AddWatermarkRequest; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.PdfUtils; import stirling.software.common.util.RegexPatternUtils; +import stirling.software.common.util.WatermarkRandomizer; import stirling.software.common.util.WebResponseUtils; +@Slf4j @RestController @RequestMapping("/api/v1/security") @Tag(name = "Security", description = "Security APIs") @@ -66,6 +69,149 @@ public class WatermarkController { }); } + /** + * Validates watermark request parameters and enforces safety caps. Throws + * IllegalArgumentException with descriptive messages for validation failures. + */ + private void validateWatermarkRequest(AddWatermarkRequest request) { + // Validate opacity bounds (0.0 - 1.0) + float opacity = request.getOpacity(); + if (opacity < 0.0f || opacity > 1.0f) { + String errorMsg = + String.format("Opacity must be between 0.0 and 1.0, but got: %.2f", opacity); + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + // Validate rotation range: rotationMin <= rotationMax + Float rotationMin = request.getRotationMin(); + Float rotationMax = request.getRotationMax(); + if (rotationMin != null && rotationMax != null && rotationMin > rotationMax) { + String errorMsg = + String.format( + "Rotation minimum (%.2f) must be less than or equal to rotation maximum (%.2f)", + rotationMin, rotationMax); + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + // Validate font size range: fontSizeMin <= fontSizeMax + Float fontSizeMin = request.getFontSizeMin(); + Float fontSizeMax = request.getFontSizeMax(); + if (fontSizeMin != null && fontSizeMax != null && fontSizeMin > fontSizeMax) { + String errorMsg = + String.format( + "Font size minimum (%.2f) must be less than or equal to font size maximum (%.2f)", + fontSizeMin, fontSizeMax); + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + // Validate a color format when not using random color + String customColor = request.getCustomColor(); + Boolean randomColor = request.getRandomColor(); + if (customColor != null && !Boolean.TRUE.equals(randomColor)) { + // Check if color is valid hex format (#RRGGBB or #RRGGBBAA) + if (!customColor.matches("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$")) { + String errorMsg = + String.format( + "Invalid color format: '%s'. Expected hex format like #RRGGBB or #RRGGBBAA", + customColor); + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + } + + // Validate mirroring probability bounds (0.0 - 1.0) + Float mirroringProbability = request.getMirroringProbability(); + if (mirroringProbability != null + && (mirroringProbability < 0.0f || mirroringProbability > 1.0f)) { + String errorMsg = + String.format( + "Mirroring probability must be between 0.0 and 1.0, but got: %.2f", + mirroringProbability); + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + // Validate watermark type + String watermarkType = request.getWatermarkType(); + if (watermarkType == null + || (!watermarkType.equalsIgnoreCase("text") + && !watermarkType.equalsIgnoreCase("image"))) { + String errorMsg = + String.format( + "Watermark type must be 'text' or 'image', but got: '%s'", + watermarkType); + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + // Validate text watermark has text + if ("text".equalsIgnoreCase(watermarkType)) { + String watermarkText = request.getWatermarkText(); + if (watermarkText == null || watermarkText.trim().isEmpty()) { + String errorMsg = "Watermark text is required when watermark type is 'text'"; + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + } + + // Validate image watermark has image + if ("image".equalsIgnoreCase(watermarkType)) { + MultipartFile watermarkImage = request.getWatermarkImage(); + if (watermarkImage == null || watermarkImage.isEmpty()) { + String errorMsg = "Watermark image is required when watermark type is 'image'"; + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + // Validate image type - only allow common image formats + String contentType = watermarkImage.getContentType(); + String originalFilename = watermarkImage.getOriginalFilename(); + if (contentType != null && !isSupportedImageType(contentType)) { + String errorMsg = + String.format( + "Unsupported image type: '%s'. Supported types: PNG, JPG, JPEG, GIF, BMP", + contentType); + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + // Additional check based on file extension + if (originalFilename != null && !hasSupportedImageExtension(originalFilename)) { + String errorMsg = + String.format( + "Unsupported image file extension in: '%s'. Supported extensions: .png, .jpg, .jpeg, .gif, .bmp", + originalFilename); + log.warn("Validation failed: {}", errorMsg); + throw new IllegalArgumentException(errorMsg); + } + } + + log.debug("Watermark request validation passed"); + } + + /** Checks if the content type is a supported image format. */ + private boolean isSupportedImageType(String contentType) { + return contentType.equals("image/png") + || contentType.equals("image/jpeg") + || contentType.equals("image/jpg") + || contentType.equals("image/gif") + || contentType.equals("image/bmp") + || contentType.equals("image/x-ms-bmp"); + } + + /** Checks if the filename has a supported image extension. */ + private boolean hasSupportedImageExtension(String filename) { + String lowerFilename = filename.toLowerCase(); + return lowerFilename.endsWith(".png") + || lowerFilename.endsWith(".jpg") + || lowerFilename.endsWith(".jpeg") + || lowerFilename.endsWith(".gif") + || lowerFilename.endsWith(".bmp"); + } + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-watermark") @Operation( summary = "Add watermark to a PDF file", @@ -74,32 +220,37 @@ public class WatermarkController { + " watermark type (text or image), rotation, opacity, width spacer, and" + " height spacer. Input:PDF Output:PDF Type:SISO") public ResponseEntity addWatermark(@ModelAttribute AddWatermarkRequest request) - throws IOException, Exception { + throws IOException { MultipartFile pdfFile = request.getFileInput(); String pdfFileName = pdfFile.getOriginalFilename(); if (pdfFileName != null && (pdfFileName.contains("..") || pdfFileName.startsWith("/"))) { + log.error("Security violation: Invalid file path in pdfFile: {}", pdfFileName); throw new SecurityException("Invalid file path in pdfFile"); } String watermarkType = request.getWatermarkType(); - String watermarkText = request.getWatermarkText(); MultipartFile watermarkImage = request.getWatermarkImage(); if (watermarkImage != null) { String watermarkImageFileName = watermarkImage.getOriginalFilename(); if (watermarkImageFileName != null && (watermarkImageFileName.contains("..") || watermarkImageFileName.startsWith("/"))) { + log.error( + "Security violation: Invalid file path in watermarkImage: {}", + watermarkImageFileName); throw new SecurityException("Invalid file path in watermarkImage"); } } - String alphabet = request.getAlphabet(); - float fontSize = request.getFontSize(); - float rotation = request.getRotation(); - float opacity = request.getOpacity(); - int widthSpacer = request.getWidthSpacer(); - int heightSpacer = request.getHeightSpacer(); - String customColor = request.getCustomColor(); + + // Validate request parameters and enforce safety caps + validateWatermarkRequest(request); + + // Extract new fields with defaults for backward compatibility + Integer count = (request.getCount() != null) ? request.getCount() : 1; boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage()); + // Create a randomizer with optional seed for deterministic behavior + WatermarkRandomizer randomizer = new WatermarkRandomizer(request.getSeed()); + // Load the input PDF PDDocument document = pdfDocumentFactory.load(pdfFile); @@ -113,31 +264,13 @@ public class WatermarkController { // Set transparency PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState(); - graphicsState.setNonStrokingAlphaConstant(opacity); + graphicsState.setNonStrokingAlphaConstant(request.getOpacity()); contentStream.setGraphicsStateParameters(graphicsState); if ("text".equalsIgnoreCase(watermarkType)) { - addTextWatermark( - contentStream, - watermarkText, - document, - page, - rotation, - widthSpacer, - heightSpacer, - fontSize, - alphabet, - customColor); + addTextWatermark(contentStream, document, page, request, randomizer); } else if ("image".equalsIgnoreCase(watermarkType)) { - addImageWatermark( - contentStream, - watermarkImage, - document, - page, - rotation, - widthSpacer, - heightSpacer, - fontSize); + addImageWatermark(contentStream, document, page, request, randomizer); } // Close the content stream @@ -158,19 +291,73 @@ public class WatermarkController { private void addTextWatermark( PDPageContentStream contentStream, - String watermarkText, PDDocument document, PDPage page, - float rotation, - int widthSpacer, - int heightSpacer, - float fontSize, - String alphabet, - String colorString) + AddWatermarkRequest request, + WatermarkRandomizer randomizer) throws IOException { - String resourceDir = ""; - PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); - resourceDir = + + String watermarkText = request.getWatermarkText(); + String alphabet = request.getAlphabet(); + String colorString = request.getCustomColor(); + float rotation = request.getRotation(); + int widthSpacer = request.getWidthSpacer(); + int heightSpacer = request.getHeightSpacer(); + float fontSize = request.getFontSize(); + + // Extract new fields with defaults + int count = (request.getCount() != null) ? request.getCount() : 1; + boolean randomPosition = Boolean.TRUE.equals(request.getRandomPosition()); + boolean randomFont = Boolean.TRUE.equals(request.getRandomFont()); + boolean randomColor = Boolean.TRUE.equals(request.getRandomColor()); + boolean perLetterFont = Boolean.TRUE.equals(request.getPerLetterFont()); + boolean perLetterColor = Boolean.TRUE.equals(request.getPerLetterColor()); + boolean perLetterSize = Boolean.TRUE.equals(request.getPerLetterSize()); + boolean perLetterOrientation = Boolean.TRUE.equals(request.getPerLetterOrientation()); + boolean shadingRandom = Boolean.TRUE.equals(request.getShadingRandom()); + String shading = request.getShading(); + + float rotationMin = + (request.getRotationMin() != null) ? request.getRotationMin() : rotation; + float rotationMax = + (request.getRotationMax() != null) ? request.getRotationMax() : rotation; + float fontSizeMin = + (request.getFontSizeMin() != null) ? request.getFontSizeMin() : fontSize; + float fontSizeMax = + (request.getFontSizeMax() != null) ? request.getFontSizeMax() : fontSize; + float margin = (request.getMargin() != null) ? request.getMargin() : 10f; + + // Extract per-letter configuration with defaults + int perLetterFontCount = + (request.getPerLetterFontCount() != null) ? request.getPerLetterFontCount() : 2; + int perLetterColorCount = + (request.getPerLetterColorCount() != null) ? request.getPerLetterColorCount() : 4; + float perLetterSizeMin = + (request.getPerLetterSizeMin() != null) ? request.getPerLetterSizeMin() : 10f; + float perLetterSizeMax = + (request.getPerLetterSizeMax() != null) ? request.getPerLetterSizeMax() : 100f; + float perLetterOrientationMin = + (request.getPerLetterOrientationMin() != null) + ? request.getPerLetterOrientationMin() + : 0f; + float perLetterOrientationMax = + (request.getPerLetterOrientationMax() != null) + ? request.getPerLetterOrientationMax() + : 360f; + + // Parse bounds if provided + Float boundsX = null, boundsY = null, boundsWidth = null, boundsHeight = null; + if (request.getBounds() != null && !request.getBounds().isEmpty()) { + String[] boundsParts = request.getBounds().split(","); + if (boundsParts.length == 4) { + boundsX = Float.parseFloat(boundsParts[0].trim()); + boundsY = Float.parseFloat(boundsParts[1].trim()); + boundsWidth = Float.parseFloat(boundsParts[2].trim()); + boundsHeight = Float.parseFloat(boundsParts[3].trim()); + } + } + + String resourceDir = switch (alphabet) { case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf"; case "japanese" -> "static/fonts/Meiryo.ttf"; @@ -183,6 +370,8 @@ public class WatermarkController { ClassPathResource classPathResource = new ClassPathResource(resourceDir); String fileExtension = resourceDir.substring(resourceDir.lastIndexOf(".")); File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile(); + + PDFont font; try (InputStream is = classPathResource.getInputStream(); FileOutputStream os = new FileOutputStream(tempFile)) { IOUtils.copy(is, os); @@ -191,63 +380,144 @@ public class WatermarkController { Files.deleteIfExists(tempFile.toPath()); } - contentStream.setFont(font, fontSize); - - Color redactColor; - try { - if (!colorString.startsWith("#")) { - colorString = "#" + colorString; - } - redactColor = Color.decode(colorString); - } catch (NumberFormatException e) { - - redactColor = Color.LIGHT_GRAY; - } - contentStream.setNonStrokingColor(redactColor); - String[] textLines = RegexPatternUtils.getInstance().getEscapedNewlinePattern().split(watermarkText); - float maxLineWidth = 0; - for (int i = 0; i < textLines.length; ++i) { - maxLineWidth = Math.max(maxLineWidth, font.getStringWidth(textLines[i])); - } - - // Set size and location of text watermark - float watermarkWidth = widthSpacer + maxLineWidth * fontSize / 1000; - float watermarkHeight = heightSpacer + fontSize * textLines.length; float pageWidth = page.getMediaBox().getWidth(); float pageHeight = page.getMediaBox().getHeight(); - // Calculating the new width and height depending on the angle. - float radians = (float) Math.toRadians(rotation); - float newWatermarkWidth = - (float) - (Math.abs(watermarkWidth * Math.cos(radians)) - + Math.abs(watermarkHeight * Math.sin(radians))); - float newWatermarkHeight = - (float) - (Math.abs(watermarkWidth * Math.sin(radians)) - + Math.abs(watermarkHeight * Math.cos(radians))); + // Determine positions based on a randomPosition flag + java.util.List positions; + if (randomPosition) { + // Generate random positions + positions = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + // Use approximate watermark dimensions for positioning + float approxWidth = widthSpacer + 100; // Approximate + float approxHeight = heightSpacer + fontSize * textLines.length; + float[] pos = + randomizer.generateRandomPosition( + pageWidth, + pageHeight, + approxWidth, + approxHeight, + margin, + boundsX, + boundsY, + boundsWidth, + boundsHeight); + positions.add(pos); + } + } else { + // Generate grid positions (backward compatible) + float watermarkWidth = 100 * fontSize / 1000; + float watermarkHeight = fontSize * textLines.length; + positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + } - // Calculating the number of rows and columns. + // Define available fonts for random selection + java.util.List availableFonts = + java.util.Arrays.asList( + "Helvetica", + "Times-Roman", + "Courier", + "Helvetica-Bold", + "Times-Bold", + "Courier-Bold"); - int watermarkRows = (int) (pageHeight / newWatermarkHeight + 1); - int watermarkCols = (int) (pageWidth / newWatermarkWidth + 1); + // Render each watermark instance + for (float[] pos : positions) { + float x = pos[0]; + float y = pos[1]; - // Add the text watermark - for (int i = 0; i <= watermarkRows; i++) { - for (int j = 0; j <= watermarkCols; j++) { + // Determine the font for this watermark instance + PDFont wmFont; + if (randomFont) { + try { + String selectedFontName = randomizer.selectRandomFont(availableFonts); + wmFont = new PDType1Font(Standard14Fonts.getMappedFontName(selectedFontName)); + } catch (Exception e) { + log.warn("Failed to load random font, using base font instead", e); + wmFont = font; // Fall back to the base font loaded earlier + } + } else { + wmFont = font; // Use the base font loaded from alphabet selection + } + + // Determine rotation for this watermark + float wmRotation = randomizer.generateRandomRotation(rotationMin, rotationMax); + + // Determine font size for this watermark + float wmFontSize = randomizer.generateRandomFontSize(fontSizeMin, fontSizeMax); + + // Determine color for this watermark + Color wmColor; + if (randomColor) { + wmColor = randomizer.generateRandomColor(true); + } else { + try { + String colorStr = colorString; + if (!colorStr.startsWith("#")) { + colorStr = "#" + colorStr; + } + wmColor = Color.decode(colorStr); + } catch (Exception e) { + wmColor = Color.LIGHT_GRAY; + } + } + + // Determine and apply shading style + String wmShading = + shadingRandom + ? randomizer.selectRandomShading( + java.util.Arrays.asList("none", "light", "dark")) + : (shading != null ? shading : "none"); + + // Apply shading by adjusting color intensity + wmColor = applyShadingToColor(wmColor, wmShading); + + // Render text with per-letter variations if enabled + if (perLetterFont || perLetterColor || perLetterSize || perLetterOrientation) { + renderTextWithPerLetterVariations( + contentStream, + document, + textLines, + wmFont, + wmFontSize, + wmColor, + wmRotation, + x, + y, + perLetterFont, + perLetterColor, + perLetterSize, + perLetterOrientation, + perLetterSizeMin, + perLetterSizeMax, + perLetterFontCount, + perLetterColorCount, + perLetterOrientationMin, + perLetterOrientationMax, + randomizer); + } else { + // Standard rendering without per-letter variations + contentStream.setFont(wmFont, wmFontSize); + contentStream.setNonStrokingColor(wmColor); contentStream.beginText(); contentStream.setTextMatrix( - Matrix.getRotateInstance( - (float) Math.toRadians(rotation), - j * newWatermarkWidth, - i * newWatermarkHeight)); + Matrix.getRotateInstance((float) Math.toRadians(wmRotation), x, y)); - for (int k = 0; k < textLines.length; ++k) { - contentStream.showText(textLines[k]); - contentStream.newLineAtOffset(0, -fontSize); + for (String textLine : textLines) { + contentStream.showText(textLine); + contentStream.newLineAtOffset(0, -wmFontSize); } contentStream.endText(); @@ -255,25 +525,138 @@ public class WatermarkController { } } + private void renderTextWithPerLetterVariations( + PDPageContentStream contentStream, + PDDocument document, + String[] textLines, + PDFont baseFont, + float baseFontSize, + Color baseColor, + float baseRotation, + float startX, + float startY, + boolean perLetterFont, + boolean perLetterColor, + boolean perLetterSize, + boolean perLetterOrientation, + float fontSizeMin, + float fontSizeMax, + int perLetterFontCount, + int perLetterColorCount, + float perLetterOrientationMin, + float perLetterOrientationMax, + WatermarkRandomizer randomizer) + throws IOException { + + float currentX = startX; + float currentY = startY; + + for (String line : textLines) { + currentX = startX; + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + String charStr = String.valueOf(c); + + // Determine per-letter attributes + float letterSize = + perLetterSize + ? randomizer.generateRandomFontSize(fontSizeMin, fontSizeMax) + : baseFontSize; + + Color letterColor = + perLetterColor + ? randomizer.generateRandomColorFromPalette(perLetterColorCount) + : baseColor; + + float letterRotation = + perLetterOrientation + ? randomizer.generatePerLetterRotationInRange( + perLetterOrientationMin, perLetterOrientationMax) + : baseRotation; + + // Determine per-letter font + PDFont letterFont = baseFont; + if (perLetterFont) { + try { + String randomFontName = + randomizer.selectRandomFontFromCount(perLetterFontCount); + letterFont = + new PDType1Font(Standard14Fonts.getMappedFontName(randomFontName)); + } catch (Exception e) { + // Fall back to base font if font loading fails + log.warn("Failed to load random font, using base font instead", e); + } + } + + // Set font and color + contentStream.setFont(letterFont, letterSize); + contentStream.setNonStrokingColor(letterColor); + + // Render the character + contentStream.beginText(); + contentStream.setTextMatrix( + Matrix.getRotateInstance( + (float) Math.toRadians(letterRotation), currentX, currentY)); + contentStream.showText(charStr); + contentStream.endText(); + + // Advance position + float charWidth = letterFont.getStringWidth(charStr) * letterSize / 1000; + currentX += charWidth; + } + currentY -= baseFontSize; + } + } + private void addImageWatermark( PDPageContentStream contentStream, - MultipartFile watermarkImage, PDDocument document, PDPage page, - float rotation, - int widthSpacer, - int heightSpacer, - float fontSize) + AddWatermarkRequest request, + WatermarkRandomizer randomizer) throws IOException { + MultipartFile watermarkImage = request.getWatermarkImage(); + float rotation = request.getRotation(); + int widthSpacer = request.getWidthSpacer(); + int heightSpacer = request.getHeightSpacer(); + float fontSize = request.getFontSize(); + + // Extract new fields with defaults + int count = (request.getCount() != null) ? request.getCount() : 1; + boolean randomPosition = Boolean.TRUE.equals(request.getRandomPosition()); + boolean randomMirroring = Boolean.TRUE.equals(request.getRandomMirroring()); + float mirroringProbability = + (request.getMirroringProbability() != null) + ? request.getMirroringProbability() + : 0.5f; + float imageScale = (request.getImageScale() != null) ? request.getImageScale() : 1.0f; + float rotationMin = + (request.getRotationMin() != null) ? request.getRotationMin() : rotation; + float rotationMax = + (request.getRotationMax() != null) ? request.getRotationMax() : rotation; + float margin = (request.getMargin() != null) ? request.getMargin() : 10f; + + // Parse bounds if provided + Float boundsX = null, boundsY = null, boundsWidth = null, boundsHeight = null; + if (request.getBounds() != null && !request.getBounds().isEmpty()) { + String[] boundsParts = request.getBounds().split(","); + if (boundsParts.length == 4) { + boundsX = Float.parseFloat(boundsParts[0].trim()); + boundsY = Float.parseFloat(boundsParts[1].trim()); + boundsWidth = Float.parseFloat(boundsParts[2].trim()); + boundsHeight = Float.parseFloat(boundsParts[3].trim()); + } + } + // Load the watermark image BufferedImage image = ImageIO.read(watermarkImage.getInputStream()); - // Compute width based on original aspect ratio + // Compute width based on an original aspect ratio float aspectRatio = (float) image.getWidth() / (float) image.getHeight(); - // Desired physical height (in PDF points) - float desiredPhysicalHeight = fontSize; + // Desired physical height (in PDF points) with scale applied + float desiredPhysicalHeight = fontSize * imageScale; // Desired physical width based on the aspect ratio float desiredPhysicalWidth = desiredPhysicalHeight * aspectRatio; @@ -281,35 +664,108 @@ public class WatermarkController { // Convert the BufferedImage to PDImageXObject PDImageXObject xobject = LosslessFactory.createFromImage(document, image); - // Calculate the number of rows and columns for watermarks + // Get page dimensions float pageWidth = page.getMediaBox().getWidth(); float pageHeight = page.getMediaBox().getHeight(); - int watermarkRows = - (int) ((pageHeight + heightSpacer) / (desiredPhysicalHeight + heightSpacer)); - int watermarkCols = - (int) ((pageWidth + widthSpacer) / (desiredPhysicalWidth + widthSpacer)); - for (int i = 0; i < watermarkRows; i++) { - for (int j = 0; j < watermarkCols; j++) { - float x = j * (desiredPhysicalWidth + widthSpacer); - float y = i * (desiredPhysicalHeight + heightSpacer); - - // Save the graphics state - contentStream.saveGraphicsState(); - - // Create rotation matrix and rotate - contentStream.transform( - Matrix.getTranslateInstance( - x + desiredPhysicalWidth / 2, y + desiredPhysicalHeight / 2)); - contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0)); - contentStream.transform( - Matrix.getTranslateInstance( - -desiredPhysicalWidth / 2, -desiredPhysicalHeight / 2)); - - // Draw the image and restore the graphics state - contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight); - contentStream.restoreGraphicsState(); + // Determine positions based on a randomPosition flag + java.util.List positions; + if (randomPosition) { + // Generate random positions + positions = new java.util.ArrayList<>(); + for (int i = 0; i < count; i++) { + float[] pos = + randomizer.generateRandomPosition( + pageWidth, + pageHeight, + desiredPhysicalWidth, + desiredPhysicalHeight, + margin, + boundsX, + boundsY, + boundsWidth, + boundsHeight); + positions.add(pos); } + } else { + // Generate grid positions (backward compatible) + positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + desiredPhysicalWidth + widthSpacer, + desiredPhysicalHeight + heightSpacer, + widthSpacer, + heightSpacer, + count); + } + + // Render each watermark instance + for (float[] pos : positions) { + float x = pos[0]; + float y = pos[1]; + + // Determine rotation for this watermark + float wmRotation = randomizer.generateRandomRotation(rotationMin, rotationMax); + + // Determine if this watermark should be mirrored + boolean shouldMirror = randomMirroring && randomizer.shouldMirror(mirroringProbability); + + // Save the graphics state + contentStream.saveGraphicsState(); + + // Translate to center of image position + contentStream.transform( + Matrix.getTranslateInstance( + x + desiredPhysicalWidth / 2, y + desiredPhysicalHeight / 2)); + + // Apply rotation + contentStream.transform(Matrix.getRotateInstance(Math.toRadians(wmRotation), 0, 0)); + + // Apply mirroring if needed (horizontal flip) + if (shouldMirror) { + contentStream.transform(Matrix.getScaleInstance(-1, 1)); + } + + // Translate back to draw from corner + contentStream.transform( + Matrix.getTranslateInstance( + -desiredPhysicalWidth / 2, -desiredPhysicalHeight / 2)); + + // Draw the image and restore the graphics state + contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight); + contentStream.restoreGraphicsState(); } } + + /** + * Applies shading to a color by adjusting its intensity. + * + * @param color Original color + * @param shading Shading style: "none", "light", or "dark" + * @return Color with shading applied + */ + private Color applyShadingToColor(Color color, String shading) { + if (shading == null || "none".equalsIgnoreCase(shading)) { + return color; + } + + int r = color.getRed(); + int g = color.getGreen(); + int b = color.getBlue(); + + if ("light".equalsIgnoreCase(shading)) { + // Lighten the color by moving towards white + r = r + (255 - r) / 2; + g = g + (255 - g) / 2; + b = b + (255 - b) / 2; + } else if ("dark".equalsIgnoreCase(shading)) { + // Darken the color by moving towards black + r = r / 2; + g = g / 2; + b = b / 2; + } + + return new Color(r, g, b); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddWatermarkRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddWatermarkRequest.java index 00408c55e..e5737f018 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddWatermarkRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddWatermarkRequest.java @@ -4,6 +4,12 @@ import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; + import lombok.Data; import lombok.EqualsAndHashCode; @@ -54,4 +60,151 @@ public class AddWatermarkRequest extends PDFFile { defaultValue = "false", requiredMode = Schema.RequiredMode.REQUIRED) private Boolean convertPDFToImage; + + // New fields for enhanced watermarking (Phase 1) + + @Schema(description = "Number of watermark instances per page or document", defaultValue = "1") + @Min(value = 1, message = "Count must be at least 1") + @Max(value = 1000, message = "Count must not exceed 1000") + private Integer count; + + @Schema(description = "Enable random positioning of watermarks", defaultValue = "false") + private Boolean randomPosition; + + @Schema( + description = "Minimum rotation angle in degrees (used when rotation range is enabled)", + defaultValue = "0") + @DecimalMin(value = "-360.0", message = "Rotation minimum must be >= -360") + @DecimalMax(value = "360.0", message = "Rotation minimum must be <= 360") + private Float rotationMin; + + @Schema( + description = "Maximum rotation angle in degrees (used when rotation range is enabled)", + defaultValue = "0") + @DecimalMin(value = "-360.0", message = "Rotation maximum must be >= -360") + @DecimalMax(value = "360.0", message = "Rotation maximum must be <= 360") + private Float rotationMax; + + @Schema(description = "Enable random mirroring of watermarks", defaultValue = "false") + private Boolean randomMirroring; + + @Schema( + description = "Probability of mirroring when randomMirroring is enabled (0.0 - 1.0)", + defaultValue = "0.5") + @DecimalMin(value = "0.0", message = "Mirroring probability must be >= 0.0") + @DecimalMax(value = "1.0", message = "Mirroring probability must be <= 1.0") + private Float mirroringProbability; + + @Schema(description = "Specific font name to use for text watermarks") + private String fontName; + + @Schema( + description = "Enable random font selection for text watermarks", + defaultValue = "false") + private Boolean randomFont; + + @Schema( + description = "Minimum font size (used when font size range is enabled)", + defaultValue = "10") + @DecimalMin(value = "1.0", message = "Font size minimum must be >= 1.0") + @DecimalMax(value = "500.0", message = "Font size minimum must be <= 500.0") + private Float fontSizeMin; + + @Schema( + description = "Maximum font size (used when font size range is enabled)", + defaultValue = "100") + @DecimalMin(value = "1.0", message = "Font size maximum must be >= 1.0") + @DecimalMax(value = "500.0", message = "Font size maximum must be <= 500.0") + private Float fontSizeMax; + + @Schema(description = "Enable random color selection for watermarks", defaultValue = "false") + private Boolean randomColor; + + @Schema( + description = "Enable per-letter font variation in text watermarks", + defaultValue = "false") + private Boolean perLetterFont; + + @Schema( + description = "Enable per-letter color variation in text watermarks", + defaultValue = "false") + private Boolean perLetterColor; + + @Schema( + description = "Enable per-letter size variation in text watermarks", + defaultValue = "false") + private Boolean perLetterSize; + + @Schema( + description = "Enable per-letter orientation variation in text watermarks", + defaultValue = "false") + private Boolean perLetterOrientation; + + @Schema( + description = "Number of fonts to randomly select from for per-letter font variation", + defaultValue = "2") + @Min(value = 1, message = "Font count must be at least 1") + @Max(value = 20, message = "Font count must not exceed 20") + private Integer perLetterFontCount; + + @Schema(description = "Minimum font size for per-letter size variation", defaultValue = "10") + @DecimalMin(value = "1.0", message = "Per-letter size minimum must be >= 1.0") + @DecimalMax(value = "500.0", message = "Per-letter size minimum must be <= 500.0") + private Float perLetterSizeMin; + + @Schema(description = "Maximum font size for per-letter size variation", defaultValue = "100") + @DecimalMin(value = "1.0", message = "Per-letter size maximum must be >= 1.0") + @DecimalMax(value = "500.0", message = "Per-letter size maximum must be <= 500.0") + private Float perLetterSizeMax; + + @Schema( + description = "Number of colors to randomly select from for per-letter color variation", + defaultValue = "4") + @Min(value = 1, message = "Color count must be at least 1") + @Max(value = 20, message = "Color count must not exceed 20") + private Integer perLetterColorCount; + + @Schema( + description = "Minimum rotation angle in degrees for per-letter orientation variation", + defaultValue = "0") + @DecimalMin(value = "-360.0", message = "Per-letter orientation minimum must be >= -360") + @DecimalMax(value = "360.0", message = "Per-letter orientation minimum must be <= 360") + private Float perLetterOrientationMin; + + @Schema( + description = "Maximum rotation angle in degrees for per-letter orientation variation", + defaultValue = "360") + @DecimalMin(value = "-360.0", message = "Per-letter orientation maximum must be >= -360") + @DecimalMax(value = "360.0", message = "Per-letter orientation maximum must be <= 360") + private Float perLetterOrientationMax; + + @Schema(description = "Shading style for text watermarks (e.g., 'none', 'light', 'dark')") + private String shading; + + @Schema(description = "Enable random shading selection for watermarks", defaultValue = "false") + private Boolean shadingRandom; + + @Schema(description = "Random seed for deterministic randomness (optional, for testing)") + private Long seed; + + @Schema(description = "Minimum margin from page edges in points", defaultValue = "10") + @DecimalMin(value = "0.0", message = "Margin must be >= 0.0") + @DecimalMax(value = "500.0", message = "Margin must be <= 500.0") + private Float margin; + + @Schema( + description = + "Bounding box constraint for watermark placement (format: x,y,width,height)") + @Pattern( + regexp = + "^(\\d+(\\.\\d+)?),\\s*(\\d+(\\.\\d+)?),\\s*(\\d+(\\.\\d+)?),\\s*(\\d+(\\.\\d+)?)$", + message = "Bounds must be in format: x,y,width,height") + private String bounds; + + @Schema( + description = "Scale factor for image watermarks (1.0 = original size)", + defaultValue = "1.0") + @DecimalMin(value = "0.1", message = "Image scale must be >= 0.1") + @DecimalMax(value = "10.0", message = "Image scale must be <= 10.0") + private Float imageScale; } diff --git a/app/core/src/main/resources/templates/security/add-watermark.html b/app/core/src/main/resources/templates/security/add-watermark.html index 99cfc62fd..9f73bd591 100644 --- a/app/core/src/main/resources/templates/security/add-watermark.html +++ b/app/core/src/main/resources/templates/security/add-watermark.html @@ -30,6 +30,7 @@ +
-
- - +
+ +
+
@@ -93,38 +95,242 @@ appendPercentageSymbol(); -
- - -
-
- - -
-
- - -
-
- -
- -
- +
+ + +
+
+ + +
+ +
+ +
+ +
+ +
+ + +
+ +
+ +
+ + + Number of watermark instances per page (1-1000) +
+ + +
+
+ + +
+ Enable random placement instead of grid layout +
+ +
+ + + Minimum distance from page edges (0-500) +
+ +
+ + + Constrain placement to specific area (format: x,y,width,height) +
+ + +
+ + +
+ + + + +
+
+ + + For images only +
+
+ + + + +
+
+ + +
+ Enable random font selection for text watermarks +
+ +
+ + + Leave empty for default font +
+ +
+ + +
+ + + + +
+
+ + +
+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + Apply variations at the letter level for dynamic appearance +
+ + +
+
+ + +
+
+ +
+ + +
+ + + + +
+ + + For deterministic randomness (testing) +
+
+
@@ -141,7 +347,7 @@ const watermarkType = document.getElementById('watermarkType').value; const watermarkTextGroup = document.getElementById('watermarkTextGroup'); const watermarkImageGroup = document.getElementById('watermarkImageGroup'); - const alphabetGroup = document.getElementById('alphabetGroup'); // This is the new addition + const alphabetGroup = document.getElementById('alphabetGroup'); const watermarkText = document.getElementById('watermarkText'); const watermarkImage = document.getElementById('watermarkImage'); @@ -154,6 +360,17 @@ watermarkImageGroup.style.display = 'none'; watermarkImage.required = false; alphabetGroup.style.display = 'block'; + + // Show text-specific controls + const textSpecific = ['fontGroup', 'fontNameGroup', 'perLetterGroup', 'shadingGroup', 'shadingFixedGroup']; + textSpecific.forEach(id => { + const elem = document.getElementById(id); + if (elem) elem.style.display = 'block'; + }); + + // Hide image-specific controls + const imageScaleGroup = document.getElementById('imageScaleGroup'); + if (imageScaleGroup) imageScaleGroup.style.display = 'none'; } else if (watermarkType === 'image') { if (watermarkImage.hasAttribute('required')) { watermarkImage.removeAttribute('required'); @@ -163,8 +380,266 @@ watermarkImageGroup.style.display = 'block'; watermarkImage.required = true; alphabetGroup.style.display = 'none'; + + // Hide text-specific controls + const textSpecific = ['fontGroup', 'fontNameGroup', 'perLetterGroup', 'shadingGroup', 'shadingFixedGroup']; + textSpecific.forEach(id => { + const elem = document.getElementById(id); + if (elem) elem.style.display = 'none'; + }); + + // Show image-specific controls + const imageScaleGroup = document.getElementById('imageScaleGroup'); + if (imageScaleGroup) imageScaleGroup.style.display = 'block'; } } + + function toggleRotationMode() { + const mode = document.getElementById('rotationMode').value; + const rotationFixed = document.getElementById('rotation'); + const rotationMin = document.getElementById('rotationMin'); + const rotationMax = document.getElementById('rotationMax'); + + if (mode === 'fixed') { + document.getElementById('rotationFixedGroup').style.display = 'block'; + document.getElementById('rotationRangeGroup').style.display = 'none'; + if (rotationFixed) rotationFixed.disabled = false; + if (rotationMin) rotationMin.disabled = true; + if (rotationMax) rotationMax.disabled = true; + } else { + document.getElementById('rotationFixedGroup').style.display = 'none'; + document.getElementById('rotationRangeGroup').style.display = 'block'; + if (rotationFixed) rotationFixed.disabled = true; + if (rotationMin) rotationMin.disabled = false; + if (rotationMax) rotationMax.disabled = false; + } + } + + function toggleMirroringProbability() { + const enabled = document.getElementById('randomMirroring').checked; + document.getElementById('mirroringProbabilityGroup').style.display = + enabled ? 'block' : 'none'; + } + + function toggleFontMode() { + const randomFont = document.getElementById('randomFont').checked; + document.getElementById('fontNameGroup').style.display = + randomFont ? 'none' : 'block'; + } + + function toggleFontSizeMode() { + const mode = document.getElementById('fontSizeMode').value; + const fontSizeFixed = document.getElementById('fontSize'); + const fontSizeMin = document.getElementById('fontSizeMin'); + const fontSizeMax = document.getElementById('fontSizeMax'); + + if (mode === 'fixed') { + document.getElementById('fontSizeFixedGroup').style.display = 'block'; + document.getElementById('fontSizeRangeGroup').style.display = 'none'; + if (fontSizeFixed) fontSizeFixed.disabled = false; + if (fontSizeMin) fontSizeMin.disabled = true; + if (fontSizeMax) fontSizeMax.disabled = true; + } else { + document.getElementById('fontSizeFixedGroup').style.display = 'none'; + document.getElementById('fontSizeRangeGroup').style.display = 'block'; + if (fontSizeFixed) fontSizeFixed.disabled = true; + if (fontSizeMin) fontSizeMin.disabled = false; + if (fontSizeMax) fontSizeMax.disabled = false; + } + } + + function toggleColorMode() { + const random = document.getElementById('randomColor').checked; + document.getElementById('customColorGroup').style.display = + random ? 'none' : 'block'; + } + + function toggleShadingMode() { + const random = document.getElementById('shadingRandom').checked; + document.getElementById('shadingFixedGroup').style.display = + random ? 'none' : 'block'; + } + + function togglePerLetterFontConfig() { + const checkbox = document.getElementById('perLetterFont'); + const config = document.getElementById('perLetterFontConfig'); + config.style.display = checkbox.checked ? 'block' : 'none'; + } + + function togglePerLetterColorConfig() { + const checkbox = document.getElementById('perLetterColor'); + const config = document.getElementById('perLetterColorConfig'); + config.style.display = checkbox.checked ? 'block' : 'none'; + } + + function togglePerLetterSizeConfig() { + const checkbox = document.getElementById('perLetterSize'); + const config = document.getElementById('perLetterSizeConfig'); + config.style.display = checkbox.checked ? 'block' : 'none'; + } + + function togglePerLetterOrientationConfig() { + const checkbox = document.getElementById('perLetterOrientation'); + const config = document.getElementById('perLetterOrientationConfig'); + config.style.display = checkbox.checked ? 'block' : 'none'; + } + + // Add range validation + function addRangeValidation(minId, maxId, errorMsgId) { + const minInput = document.getElementById(minId); + const maxInput = document.getElementById(maxId); + const errorMsg = document.getElementById(errorMsgId); + + if (!minInput || !maxInput) return; + + function validate() { + const min = parseFloat(minInput.value); + const max = parseFloat(maxInput.value); + + if (min > max) { + minInput.classList.add('is-invalid'); + maxInput.classList.add('is-invalid'); + if (errorMsg) errorMsg.style.display = 'block'; + } else { + minInput.classList.remove('is-invalid'); + maxInput.classList.remove('is-invalid'); + if (errorMsg) errorMsg.style.display = 'none'; + } + } + + minInput.addEventListener('input', validate); + maxInput.addEventListener('input', validate); + } + + // Apply range validations on page load + document.addEventListener('DOMContentLoaded', function() { + addRangeValidation('rotationMin', 'rotationMax', 'rotationRangeError'); + addRangeValidation('fontSizeMin', 'fontSizeMax', 'fontSizeRangeError'); + addRangeValidation('perLetterSizeMin', 'perLetterSizeMax', 'perLetterSizeError'); + addRangeValidation('perLetterOrientationMin', 'perLetterOrientationMax', 'perLetterOrientationError'); + + // Initialize toggle states to disable hidden inputs + toggleRotationMode(); + toggleFontSizeMode(); + }); + + // Form submission validation + document.querySelector('form').addEventListener('submit', function(e) { + const errors = []; + + // Validate count + const count = parseInt(document.getElementById('count').value); + if (count < 1 || count > 1000) { + errors.push('Count must be between 1 and 1000'); + } + + // Validate rotation range + const rotationMode = document.getElementById('rotationMode').value; + if (rotationMode === 'range') { + const rotMin = parseFloat(document.getElementById('rotationMin').value); + const rotMax = parseFloat(document.getElementById('rotationMax').value); + if (rotMin > rotMax) { + errors.push('Rotation minimum must be less than or equal to maximum'); + } + } + + // Validate font size range + const fontSizeMode = document.getElementById('fontSizeMode').value; + if (fontSizeMode === 'range') { + const sizeMin = parseFloat(document.getElementById('fontSizeMin').value); + const sizeMax = parseFloat(document.getElementById('fontSizeMax').value); + if (sizeMin > sizeMax) { + errors.push('Font size minimum must be less than or equal to maximum'); + } + if (sizeMin < 1 || sizeMax > 500) { + errors.push('Font size must be between 1 and 500'); + } + } + + // Validate margin + const margin = parseFloat(document.getElementById('margin').value); + if (margin < 0 || margin > 500) { + errors.push('Margin must be between 0 and 500'); + } + + // Validate bounds format (if provided) + const bounds = document.getElementById('bounds').value.trim(); + if (bounds) { + const boundsPattern = /^(\d+(\.\d+)?),\s*(\d+(\.\d+)?),\s*(\d+(\.\d+)?),\s*(\d+(\.\d+)?)$/; + if (!boundsPattern.test(bounds)) { + errors.push('Bounds must be in format: x,y,width,height'); + } + } + + // Validate mirroring probability + if (document.getElementById('randomMirroring').checked) { + const prob = parseFloat(document.getElementById('mirroringProbability').value); + if (prob < 0 || prob > 1) { + errors.push('Mirroring probability must be between 0.0 and 1.0'); + } + } + + // Validate image scale + const watermarkType = document.getElementById('watermarkType').value; + if (watermarkType === 'image') { + const scale = parseFloat(document.getElementById('imageScale').value); + if (scale < 0.1 || scale > 10) { + errors.push('Image scale must be between 0.1 and 10.0'); + } + } + + // Validate opacity + const opacity = parseFloat(document.getElementById('opacityReal').value); + if (opacity < 0 || opacity > 1) { + errors.push('Opacity must be between 0.0 and 1.0'); + } + + // Validate per-letter configurations + if (document.getElementById('perLetterFont').checked) { + const fontCount = parseInt(document.getElementById('perLetterFontCount').value); + if (fontCount < 1 || fontCount > 20) { + errors.push('Per-letter font count must be between 1 and 20'); + } + } + + if (document.getElementById('perLetterColor').checked) { + const colorCount = parseInt(document.getElementById('perLetterColorCount').value); + if (colorCount < 1 || colorCount > 12) { + errors.push('Per-letter color count must be between 1 and 12'); + } + } + + if (document.getElementById('perLetterSize').checked) { + const sizeMin = parseFloat(document.getElementById('perLetterSizeMin').value); + const sizeMax = parseFloat(document.getElementById('perLetterSizeMax').value); + if (sizeMin > sizeMax) { + errors.push('Per-letter size minimum must be less than or equal to maximum'); + } + if (sizeMin < 1 || sizeMax > 500) { + errors.push('Per-letter size must be between 1 and 500'); + } + } + + if (document.getElementById('perLetterOrientation').checked) { + const orientMin = parseFloat(document.getElementById('perLetterOrientationMin').value); + const orientMax = parseFloat(document.getElementById('perLetterOrientationMax').value); + if (orientMin > orientMax) { + errors.push('Per-letter orientation minimum must be less than or equal to maximum'); + } + if (orientMin < -360 || orientMax > 360) { + errors.push('Per-letter orientation must be between -360 and 360'); + } + } + + // Display errors + if (errors.length > 0) { + e.preventDefault(); + alert('Please fix the following errors:\n\n' + errors.join('\n')); + return false; + } + + return true; + });
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerIntegrationTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerIntegrationTest.java new file mode 100644 index 000000000..8180cacb1 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerIntegrationTest.java @@ -0,0 +1,730 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.security.AddWatermarkRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Watermark Controller Integration Tests") +class WatermarkControllerIntegrationTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + + @InjectMocks private WatermarkController watermarkController; + + private MockMultipartFile testPdfFile; + private MockMultipartFile testImageFile; + + @BeforeEach + void setUp() throws IOException { + // Create a simple test PDF + PDDocument document = new PDDocument(); + document.addPage(new org.apache.pdfbox.pdmodel.PDPage()); + File tempPdf = File.createTempFile("test", ".pdf"); + document.save(tempPdf); + document.close(); + + byte[] pdfBytes = Files.readAllBytes(tempPdf.toPath()); + testPdfFile = new MockMultipartFile("fileInput", "test.pdf", "application/pdf", pdfBytes); + tempPdf.delete(); + + // Create a simple test image (1x1 pixel PNG) + byte[] imageBytes = + new byte[] { + (byte) 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + (byte) 0xC4, + (byte) 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + (byte) 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + (byte) 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + (byte) 0xAE, + 0x42, + 0x60, + (byte) 0x82 + }; + testImageFile = + new MockMultipartFile("watermarkImage", "test.png", "image/png", imageBytes); + + // Configure mock to return a real PDDocument when load is called + when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class))) + .thenAnswer( + invocation -> { + org.springframework.web.multipart.MultipartFile file = + invocation.getArgument(0); + return Loader.loadPDF(file.getBytes()); + }); + } + + @Nested + @DisplayName("Text Watermark Integration Tests") + class TextWatermarkIntegrationTests { + + @Test + @DisplayName("Should apply text watermark with fixed positioning") + void testTextWatermarkFixedPositioning() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Test Watermark"); + request.setOpacity(0.5f); + request.setFontSize(30f); + request.setRotation(45f); + request.setWidthSpacer(100); + request.setHeightSpacer(100); + request.setCustomColor("#FF0000"); + request.setConvertPDFToImage(false); + request.setRandomPosition(false); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + assertNotNull(response.getBody(), "Response body should not be null"); + assertTrue(response.getBody().length > 0, "Response should contain PDF data"); + + // Verify the output is a valid PDF + try (PDDocument resultDoc = Loader.loadPDF(response.getBody())) { + assertEquals(1, resultDoc.getNumberOfPages(), "Should have 1 page"); + } + } + + @Test + @DisplayName("Should apply text watermark with random positioning") + void testTextWatermarkRandomPositioning() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Random"); + request.setOpacity(0.7f); + request.setFontSize(20f); + request.setCustomColor("#0000FF"); + request.setConvertPDFToImage(false); + request.setRandomPosition(true); + request.setCount(5); + request.setMargin(10f); + request.setSeed(12345L); // Use seed for deterministic testing + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + assertTrue(response.getBody().length > 0, "Response should contain PDF data"); + } + + @Test + @DisplayName("Should apply text watermark with rotation range") + void testTextWatermarkWithRotationRange() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Rotated"); + request.setOpacity(0.5f); + request.setFontSize(25f); + request.setCustomColor("#00FF00"); + request.setConvertPDFToImage(false); + request.setRandomPosition(true); + request.setCount(3); + request.setRotationMin(-45f); + request.setRotationMax(45f); + request.setSeed(54321L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with per-letter font variation") + void testTextWatermarkPerLetterFont() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Mixed"); + request.setOpacity(0.6f); + request.setFontSize(30f); + request.setCustomColor("#FF00FF"); + request.setConvertPDFToImage(false); + request.setPerLetterFont(true); + request.setPerLetterFontCount(3); + request.setSeed(99999L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with per-letter color variation") + void testTextWatermarkPerLetterColor() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Colors"); + request.setOpacity(0.8f); + request.setFontSize(28f); + request.setConvertPDFToImage(false); + request.setPerLetterColor(true); + request.setPerLetterColorCount(4); + request.setSeed(11111L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with per-letter size variation") + void testTextWatermarkPerLetterSize() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Sizes"); + request.setOpacity(0.5f); + request.setConvertPDFToImage(false); + request.setPerLetterSize(true); + request.setPerLetterSizeMin(15f); + request.setPerLetterSizeMax(35f); + request.setSeed(22222L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with per-letter orientation variation") + void testTextWatermarkPerLetterOrientation() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Tilted"); + request.setOpacity(0.6f); + request.setFontSize(25f); + request.setCustomColor("#FFAA00"); + request.setConvertPDFToImage(false); + request.setPerLetterOrientation(true); + request.setPerLetterOrientationMin(-30f); + request.setPerLetterOrientationMax(30f); + request.setSeed(33333L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with all per-letter variations enabled") + void testTextWatermarkAllPerLetterVariations() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Chaos"); + request.setOpacity(0.7f); + request.setConvertPDFToImage(false); + request.setPerLetterFont(true); + request.setPerLetterFontCount(2); + request.setPerLetterColor(true); + request.setPerLetterColorCount(3); + request.setPerLetterSize(true); + request.setPerLetterSizeMin(20f); + request.setPerLetterSizeMax(40f); + request.setPerLetterOrientation(true); + request.setPerLetterOrientationMin(-20f); + request.setPerLetterOrientationMax(20f); + request.setSeed(44444L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with random font") + void testTextWatermarkRandomFont() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Random Font"); + request.setOpacity(0.5f); + request.setFontSize(30f); + request.setCustomColor("#AA00FF"); + request.setConvertPDFToImage(false); + request.setRandomFont(true); + request.setSeed(55555L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with random color") + void testTextWatermarkRandomColor() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Random Color"); + request.setOpacity(0.6f); + request.setFontSize(28f); + request.setConvertPDFToImage(false); + request.setRandomColor(true); + request.setSeed(66666L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with font size range") + void testTextWatermarkFontSizeRange() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Size Range"); + request.setOpacity(0.5f); + request.setCustomColor("#00AAFF"); + request.setConvertPDFToImage(false); + request.setRandomPosition(true); + request.setCount(3); + request.setFontSizeMin(20f); + request.setFontSizeMax(40f); + request.setSeed(77777L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with opacity variations") + void testTextWatermarkOpacityVariations() throws Exception { + // Test minimum opacity + AddWatermarkRequest request1 = new AddWatermarkRequest(); + request1.setFileInput(testPdfFile); + request1.setWatermarkType("text"); + request1.setWatermarkText("Min Opacity"); + request1.setOpacity(0.0f); + request1.setFontSize(30f); + request1.setCustomColor("#000000"); + request1.setConvertPDFToImage(false); + request1.setAlphabet("roman"); + + ResponseEntity response1 = watermarkController.addWatermark(request1); + assertEquals(200, response1.getStatusCode().value(), "Should handle min opacity"); + + // Test maximum opacity + AddWatermarkRequest request2 = new AddWatermarkRequest(); + request2.setFileInput(testPdfFile); + request2.setWatermarkType("text"); + request2.setWatermarkText("Max Opacity"); + request2.setOpacity(1.0f); + request2.setFontSize(30f); + request2.setCustomColor("#000000"); + request2.setConvertPDFToImage(false); + request2.setAlphabet("roman"); + + ResponseEntity response2 = watermarkController.addWatermark(request2); + assertEquals(200, response2.getStatusCode().value(), "Should handle max opacity"); + } + + @Test + @DisplayName("Should apply text watermark with shading") + void testTextWatermarkWithShading() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Shaded"); + request.setOpacity(0.6f); + request.setFontSize(30f); + request.setCustomColor("#FF0000"); + request.setConvertPDFToImage(false); + request.setShading("light"); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with random shading") + void testTextWatermarkRandomShading() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Random Shade"); + request.setOpacity(0.6f); + request.setFontSize(30f); + request.setCustomColor("#0000FF"); + request.setConvertPDFToImage(false); + request.setShadingRandom(true); + request.setSeed(88888L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply text watermark with custom bounds") + void testTextWatermarkWithBounds() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Bounded"); + request.setOpacity(0.5f); + request.setFontSize(25f); + request.setCustomColor("#00FF00"); + request.setConvertPDFToImage(false); + request.setRandomPosition(true); + request.setCount(3); + request.setBounds("100,100,300,200"); + request.setSeed(99000L); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + } + + @Nested + @DisplayName("Image Watermark Integration Tests") + class ImageWatermarkIntegrationTests { + + @Test + @DisplayName("Should apply image watermark with default settings") + void testImageWatermarkDefault() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("image"); + request.setWatermarkImage(testImageFile); + request.setOpacity(0.5f); + request.setConvertPDFToImage(false); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + assertTrue(response.getBody().length > 0, "Response should contain PDF data"); + } + + @Test + @DisplayName("Should apply image watermark with scaling") + void testImageWatermarkWithScaling() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("image"); + request.setWatermarkImage(testImageFile); + request.setOpacity(0.7f); + request.setImageScale(2.0f); + request.setConvertPDFToImage(false); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply image watermark with rotation") + void testImageWatermarkWithRotation() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("image"); + request.setWatermarkImage(testImageFile); + request.setOpacity(0.6f); + request.setRotation(45f); + request.setConvertPDFToImage(false); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply image watermark with rotation range") + void testImageWatermarkWithRotationRange() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("image"); + request.setWatermarkImage(testImageFile); + request.setOpacity(0.5f); + request.setRandomPosition(true); + request.setCount(3); + request.setRotationMin(-30f); + request.setRotationMax(30f); + request.setSeed(12121L); + request.setConvertPDFToImage(false); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply image watermark with mirroring") + void testImageWatermarkWithMirroring() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("image"); + request.setWatermarkImage(testImageFile); + request.setOpacity(0.6f); + request.setRandomMirroring(true); + request.setMirroringProbability(1.0f); // Always mirror for testing + request.setSeed(23232L); + request.setConvertPDFToImage(false); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply image watermark with scaling, rotation, and mirroring") + void testImageWatermarkCombined() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("image"); + request.setWatermarkImage(testImageFile); + request.setOpacity(0.7f); + request.setImageScale(1.5f); + request.setRotation(30f); + request.setRandomMirroring(true); + request.setMirroringProbability(0.5f); + request.setSeed(34343L); + request.setConvertPDFToImage(false); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + + @Test + @DisplayName("Should apply multiple image watermarks with random positioning") + void testMultipleImageWatermarksRandom() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("image"); + request.setWatermarkImage(testImageFile); + request.setOpacity(0.5f); + request.setRandomPosition(true); + request.setCount(5); + request.setMargin(20f); + request.setSeed(45454L); + request.setConvertPDFToImage(false); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + } + + @Nested + @DisplayName("Convert to Image Tests") + class ConvertToImageTests { + + @Test + @DisplayName("Should convert PDF to image after applying text watermark") + void testConvertToImageWithTextWatermark() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Convert Test"); + request.setOpacity(0.5f); + request.setFontSize(30f); + request.setCustomColor("#FF0000"); + request.setConvertPDFToImage(true); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + assertTrue(response.getBody().length > 0, "Response should contain PDF data"); + } + + @Test + @DisplayName("Should convert PDF to image after applying image watermark") + void testConvertToImageWithImageWatermark() throws Exception { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setAlphabet("roman"); + request.setFileInput(testPdfFile); + request.setWatermarkType("image"); + request.setWatermarkImage(testImageFile); + request.setOpacity(0.6f); + request.setConvertPDFToImage(true); + + ResponseEntity response = watermarkController.addWatermark(request); + + assertNotNull(response, "Response should not be null"); + assertEquals(200, response.getStatusCode().value(), "Should return 200 OK"); + } + } + + @Nested + @DisplayName("Deterministic Randomness Tests") + class DeterministicRandomnessTests { + + @Test + @DisplayName("Should produce identical results with same seed") + void testDeterministicWithSeed() throws Exception { + AddWatermarkRequest request1 = new AddWatermarkRequest(); + request1.setFileInput(testPdfFile); + request1.setWatermarkType("text"); + request1.setWatermarkText("Deterministic"); + request1.setOpacity(0.5f); + request1.setFontSize(25f); + request1.setCustomColor("#0000FF"); + request1.setRandomPosition(true); + request1.setCount(5); + request1.setSeed(99999L); + request1.setConvertPDFToImage(false); + request1.setAlphabet("roman"); + + ResponseEntity response1 = watermarkController.addWatermark(request1); + + AddWatermarkRequest request2 = new AddWatermarkRequest(); + request2.setFileInput(testPdfFile); + request2.setWatermarkType("text"); + request2.setWatermarkText("Deterministic"); + request2.setOpacity(0.5f); + request2.setFontSize(25f); + request2.setCustomColor("#0000FF"); + request2.setRandomPosition(true); + request2.setCount(5); + request2.setSeed(99999L); + request2.setConvertPDFToImage(false); + request2.setAlphabet("roman"); + + ResponseEntity response2 = watermarkController.addWatermark(request2); + + assertNotNull(response1, "First response should not be null"); + assertNotNull(response2, "Second response should not be null"); + assertEquals(200, response1.getStatusCode().value(), "First request should succeed"); + assertEquals(200, response2.getStatusCode().value(), "Second request should succeed"); + + // Note: Exact byte comparison may fail due to PDF metadata (timestamps, etc.) + // But both should produce valid PDFs of similar size + assertTrue( + Math.abs(response1.getBody().length - response2.getBody().length) < 1000, + "PDFs should be similar in size"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkValidationTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkValidationTest.java new file mode 100644 index 000000000..65c4a301f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkValidationTest.java @@ -0,0 +1,711 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.model.api.security.AddWatermarkRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; + +@DisplayName("Watermark Validation Tests") +@ExtendWith(MockitoExtension.class) +class WatermarkValidationTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + + @InjectMocks private WatermarkController watermarkController; + + private AddWatermarkRequest request; + private MockMultipartFile mockPdfFile; + + @BeforeEach + void setUp() throws Exception { + request = new AddWatermarkRequest(); + mockPdfFile = + new MockMultipartFile( + "fileInput", "test.pdf", "application/pdf", "test content".getBytes()); + request.setFileInput(mockPdfFile); + request.setWatermarkType("text"); + request.setWatermarkText("Test Watermark"); + request.setOpacity(0.5f); + request.setConvertPDFToImage(false); + + // Mock PDDocument with empty pages to avoid NullPointerException + // Use lenient() because some tests don't reach the document loading code + PDDocument mockDocument = mock(PDDocument.class); + lenient().when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(mockDocument); + + // Mock getPages() to return an empty iterable + org.apache.pdfbox.pdmodel.PDPageTree mockPageTree = + mock(org.apache.pdfbox.pdmodel.PDPageTree.class); + lenient().when(mockDocument.getPages()).thenReturn(mockPageTree); + lenient().when(mockPageTree.iterator()).thenReturn(Collections.emptyIterator()); + } + + @Nested + @DisplayName("Opacity Validation Tests") + class OpacityValidationTests { + + @Test + @DisplayName("Should reject opacity below 0.0") + void testOpacityBelowMinimum() { + request.setOpacity(-0.1f); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Opacity must be between 0.0 and 1.0"), + "Error message should mention opacity bounds"); + } + + @Test + @DisplayName("Should reject opacity above 1.0") + void testOpacityAboveMaximum() { + request.setOpacity(1.1f); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Opacity must be between 0.0 and 1.0"), + "Error message should mention opacity bounds"); + } + } + + @Nested + @DisplayName("Rotation Range Validation Tests") + class RotationRangeValidationTests { + + @Test + @DisplayName("Should accept valid rotation range") + void testValidRotationRange() { + request.setRotationMin(-45f); + request.setRotationMax(45f); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept equal rotation min and max") + void testEqualRotationMinMax() { + request.setRotationMin(30f); + request.setRotationMax(30f); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should reject rotation min greater than max") + void testRotationMinGreaterThanMax() { + request.setRotationMin(45f); + request.setRotationMax(-45f); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Rotation minimum") + && exception.getMessage().contains("must be less than or equal to"), + "Error message should mention rotation range constraint"); + } + + @Test + @DisplayName("Should accept null rotation values") + void testNullRotationValues() { + request.setRotationMin(null); + request.setRotationMax(null); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept only rotation min set") + void testOnlyRotationMinSet() { + request.setRotationMin(-30f); + request.setRotationMax(null); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept only rotation max set") + void testOnlyRotationMaxSet() { + request.setRotationMin(null); + request.setRotationMax(30f); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + } + + @Nested + @DisplayName("Font Size Range Validation Tests") + class FontSizeRangeValidationTests { + + @Test + @DisplayName("Should accept valid font size range") + void testValidFontSizeRange() { + request.setFontSizeMin(10f); + request.setFontSizeMax(50f); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept equal font size min and max") + void testEqualFontSizeMinMax() { + request.setFontSizeMin(30f); + request.setFontSizeMax(30f); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should reject font size min greater than max") + void testFontSizeMinGreaterThanMax() { + request.setFontSizeMin(50f); + request.setFontSizeMax(10f); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Font size minimum") + && exception.getMessage().contains("must be less than or equal to"), + "Error message should mention font size range constraint"); + } + + @Test + @DisplayName("Should accept null font size values") + void testNullFontSizeValues() { + request.setFontSizeMin(null); + request.setFontSizeMax(null); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + } + + @Nested + @DisplayName("Color Format Validation Tests") + class ColorFormatValidationTests { + + @Test + @DisplayName("Should accept valid 6-digit hex color") + void testValidHexColor6Digits() { + request.setCustomColor("#FF0000"); + request.setRandomColor(false); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept valid 8-digit hex color with alpha") + void testValidHexColor8Digits() { + request.setCustomColor("#FF0000AA"); + request.setRandomColor(false); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept lowercase hex color") + void testValidHexColorLowercase() { + request.setCustomColor("#ff0000"); + request.setRandomColor(false); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept mixed case hex color") + void testValidHexColorMixedCase() { + request.setCustomColor("#Ff00Aa"); + request.setRandomColor(false); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should reject hex color without hash") + void testInvalidHexColorNoHash() { + request.setCustomColor("FF0000"); + request.setRandomColor(false); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Invalid color format"), + "Error message should mention invalid color format"); + } + + @Test + @DisplayName("Should reject hex color with wrong length") + void testInvalidHexColorWrongLength() { + request.setCustomColor("#FFF"); + request.setRandomColor(false); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Invalid color format"), + "Error message should mention invalid color format"); + } + + @Test + @DisplayName("Should reject hex color with invalid characters") + void testInvalidHexColorInvalidChars() { + request.setCustomColor("#GGGGGG"); + request.setRandomColor(false); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Invalid color format"), + "Error message should mention invalid color format"); + } + + @Test + @DisplayName("Should skip color validation when using random color") + void testSkipValidationWithRandomColor() { + request.setCustomColor("invalid"); + request.setRandomColor(true); + + // Should not throw exception because random color is enabled + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should skip color validation when custom color is null") + void testSkipValidationWithNullColor() { + request.setCustomColor(null); + request.setRandomColor(false); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + } + + @Nested + @DisplayName("Mirroring Probability Validation Tests") + class MirroringProbabilityValidationTests { + + @Test + @DisplayName("Should accept valid mirroring probability values") + void testValidMirroringProbability() { + request.setMirroringProbability(0.0f); + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + + request.setMirroringProbability(0.5f); + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + + request.setMirroringProbability(1.0f); + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should reject mirroring probability below 0.0") + void testMirroringProbabilityBelowMinimum() { + request.setMirroringProbability(-0.1f); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception + .getMessage() + .contains("Mirroring probability must be between 0.0 and 1.0"), + "Error message should mention mirroring probability bounds"); + } + + @Test + @DisplayName("Should reject mirroring probability above 1.0") + void testMirroringProbabilityAboveMaximum() { + request.setMirroringProbability(1.5f); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception + .getMessage() + .contains("Mirroring probability must be between 0.0 and 1.0"), + "Error message should mention mirroring probability bounds"); + } + + @Test + @DisplayName("Should accept null mirroring probability") + void testNullMirroringProbability() { + request.setMirroringProbability(null); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + } + + @Nested + @DisplayName("Watermark Type Validation Tests") + class WatermarkTypeValidationTests { + + @Test + @DisplayName("Should accept 'text' watermark type") + void testTextWatermarkType() { + request.setWatermarkType("text"); + request.setWatermarkText("Test"); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept 'image' watermark type") + void testImageWatermarkType() { + request.setWatermarkType("image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", "test.png", "image/png", "image content".getBytes()); + request.setWatermarkImage(imageFile); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept case-insensitive watermark type") + void testCaseInsensitiveWatermarkType() { + request.setWatermarkType("TEXT"); + request.setWatermarkText("Test"); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + + request.setWatermarkType("Image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", "test.png", "image/png", "image content".getBytes()); + request.setWatermarkImage(imageFile); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should reject invalid watermark type") + void testInvalidWatermarkType() { + request.setWatermarkType("invalid"); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Watermark type must be 'text' or 'image'"), + "Error message should mention valid watermark types"); + } + + @Test + @DisplayName("Should reject null watermark type") + void testNullWatermarkType() { + request.setWatermarkType(null); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Watermark type must be 'text' or 'image'"), + "Error message should mention valid watermark types"); + } + + @Test + @DisplayName("Should reject text watermark without text") + void testTextWatermarkWithoutText() { + request.setWatermarkType("text"); + request.setWatermarkText(null); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Watermark text is required"), + "Error message should mention missing watermark text"); + } + + @Test + @DisplayName("Should reject text watermark with empty text") + void testTextWatermarkWithEmptyText() { + request.setWatermarkType("text"); + request.setWatermarkText(" "); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Watermark text is required"), + "Error message should mention missing watermark text"); + } + + @Test + @DisplayName("Should reject image watermark without image") + void testImageWatermarkWithoutImage() { + request.setWatermarkType("image"); + request.setWatermarkImage(null); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Watermark image is required"), + "Error message should mention missing watermark image"); + } + + @Test + @DisplayName("Should reject image watermark with empty image") + void testImageWatermarkWithEmptyImage() { + request.setWatermarkType("image"); + MockMultipartFile emptyImage = + new MockMultipartFile("watermarkImage", "", "image/png", new byte[0]); + request.setWatermarkImage(emptyImage); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Watermark image is required"), + "Error message should mention missing watermark image"); + } + } + + @Nested + @DisplayName("Image Type Validation Tests") + class ImageTypeValidationTests { + + @Test + @DisplayName("Should accept PNG image") + void testAcceptPngImage() { + request.setWatermarkType("image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", "test.png", "image/png", "image content".getBytes()); + request.setWatermarkImage(imageFile); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept JPG image") + void testAcceptJpgImage() { + request.setWatermarkType("image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", "test.jpg", "image/jpeg", "image content".getBytes()); + request.setWatermarkImage(imageFile); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept JPEG image") + void testAcceptJpegImage() { + request.setWatermarkType("image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", + "test.jpeg", + "image/jpeg", + "image content".getBytes()); + request.setWatermarkImage(imageFile); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept GIF image") + void testAcceptGifImage() { + request.setWatermarkType("image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", "test.gif", "image/gif", "image content".getBytes()); + request.setWatermarkImage(imageFile); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should accept BMP image") + void testAcceptBmpImage() { + request.setWatermarkType("image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", "test.bmp", "image/bmp", "image content".getBytes()); + request.setWatermarkImage(imageFile); + + assertDoesNotThrow(() -> watermarkController.addWatermark(request)); + } + + @Test + @DisplayName("Should reject unsupported image content type") + void testRejectUnsupportedImageContentType() { + request.setWatermarkType("image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", + "test.svg", + "image/svg+xml", + "image content".getBytes()); + request.setWatermarkImage(imageFile); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Unsupported image type"), + "Error message should mention unsupported image type"); + } + + @Test + @DisplayName("Should reject unsupported image file extension") + void testRejectUnsupportedImageExtension() { + request.setWatermarkType("image"); + MockMultipartFile imageFile = + new MockMultipartFile( + "watermarkImage", "test.svg", "image/png", "image content".getBytes()); + request.setWatermarkImage(imageFile); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> watermarkController.addWatermark(request)); + + assertTrue( + exception.getMessage().contains("Unsupported image file extension"), + "Error message should mention unsupported file extension"); + } + } + + @Nested + @DisplayName("Annotation-based Validation Tests") + class AnnotationBasedValidationTests { + + @Test + @DisplayName("Should enforce count minimum of 1") + void testCountMinimum() { + // Note: This tests the @Min annotation on count field + // The actual validation happens at the framework level + request.setCount(0); + // Framework validation would reject this before reaching controller + } + + @Test + @DisplayName("Should enforce count maximum of 1000") + void testCountMaximum() { + // Note: This tests the @Max annotation on count field + request.setCount(1001); + // Framework validation would reject this before reaching controller + } + + @Test + @DisplayName("Should enforce rotation min/max bounds of -360 to 360") + void testRotationBounds() { + // Note: This tests the @DecimalMin/@DecimalMax annotations + request.setRotationMin(-361f); + request.setRotationMax(361f); + // Framework validation would reject this before reaching controller + } + + @Test + @DisplayName("Should enforce font size bounds of 1.0 to 500.0") + void testFontSizeBounds() { + // Note: This tests the @DecimalMin/@DecimalMax annotations + request.setFontSizeMin(0.5f); + request.setFontSizeMax(501f); + // Framework validation would reject this before reaching controller + } + + @Test + @DisplayName("Should enforce per-letter font count bounds of 1 to 20") + void testPerLetterFontCountBounds() { + // Note: This tests the @Min/@Max annotations + request.setPerLetterFontCount(0); + request.setPerLetterFontCount(21); + // Framework validation would reject this before reaching controller + } + + @Test + @DisplayName("Should enforce per-letter color count bounds of 1 to 20") + void testPerLetterColorCountBounds() { + // Note: This tests the @Min/@Max annotations + request.setPerLetterColorCount(0); + request.setPerLetterColorCount(21); + // Framework validation would reject this before reaching controller + } + + @Test + @DisplayName("Should enforce margin bounds of 0.0 to 500.0") + void testMarginBounds() { + // Note: This tests the @DecimalMin/@DecimalMax annotations + request.setMargin(-1f); + request.setMargin(501f); + // Framework validation would reject this before reaching controller + } + + @Test + @DisplayName("Should enforce image scale bounds of 0.1 to 10.0") + void testImageScaleBounds() { + // Note: This tests the @DecimalMin/@DecimalMax annotations + request.setImageScale(0.05f); + request.setImageScale(11f); + // Framework validation would reject this before reaching controller + } + + @Test + @DisplayName("Should validate bounds format pattern") + void testBoundsFormatPattern() { + // Note: This tests the @Pattern annotation on bounds field + request.setBounds("invalid"); + // Framework validation would reject this before reaching controller + + request.setBounds("100,100,200,200"); // Valid format + // Framework validation would accept this + } + } +} From 9bcb677e53493eab9f39e47bb668ee8864f064b2 Mon Sep 17 00:00:00 2001 From: Anton Arhipov Date: Wed, 29 Oct 2025 13:25:27 +0200 Subject: [PATCH 02/20] Update app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java remove unused variable Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../SPDF/controller/api/security/WatermarkController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index e4b1fc2f5..7680554a5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -245,7 +245,6 @@ public class WatermarkController { validateWatermarkRequest(request); // Extract new fields with defaults for backward compatibility - Integer count = (request.getCount() != null) ? request.getCount() : 1; boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage()); // Create a randomizer with optional seed for deterministic behavior From 7d0b8789d5c244839eb27489649b1bd663774670 Mon Sep 17 00:00:00 2001 From: Anton Arhipov Date: Wed, 29 Oct 2025 13:32:06 +0200 Subject: [PATCH 03/20] Update app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../SPDF/controller/api/security/WatermarkController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index 7680554a5..6f28f2f5b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -526,7 +526,6 @@ public class WatermarkController { private void renderTextWithPerLetterVariations( PDPageContentStream contentStream, - PDDocument document, String[] textLines, PDFont baseFont, float baseFontSize, From 56c88fba3be74e7ecbba311caacd7fad098ab5dd Mon Sep 17 00:00:00 2001 From: Anton Arhipov Date: Wed, 29 Oct 2025 13:33:00 +0200 Subject: [PATCH 04/20] Update app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../SPDF/controller/api/security/WatermarkController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index 6f28f2f5b..abb7b62ee 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -107,7 +107,7 @@ public class WatermarkController { throw new IllegalArgumentException(errorMsg); } - // Validate a color format when not using random color + // Validate color format when not using random color String customColor = request.getCustomColor(); Boolean randomColor = request.getRandomColor(); if (customColor != null && !Boolean.TRUE.equals(randomColor)) { From a506fe43da2b918d9d9994cd6c698a4ec8cc2339 Mon Sep 17 00:00:00 2001 From: Anton Arhipov Date: Wed, 29 Oct 2025 13:33:52 +0200 Subject: [PATCH 05/20] Update app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java save handling of bound parameters Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/security/WatermarkController.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index abb7b62ee..e43a2d9f4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -349,10 +349,15 @@ public class WatermarkController { if (request.getBounds() != null && !request.getBounds().isEmpty()) { String[] boundsParts = request.getBounds().split(","); if (boundsParts.length == 4) { - boundsX = Float.parseFloat(boundsParts[0].trim()); - boundsY = Float.parseFloat(boundsParts[1].trim()); - boundsWidth = Float.parseFloat(boundsParts[2].trim()); - boundsHeight = Float.parseFloat(boundsParts[3].trim()); + try { + boundsX = Float.parseFloat(boundsParts[0].trim()); + boundsY = Float.parseFloat(boundsParts[1].trim()); + boundsWidth = Float.parseFloat(boundsParts[2].trim()); + boundsHeight = Float.parseFloat(boundsParts[3].trim()); + } catch (NumberFormatException e) { + log.error("Invalid bounds format: {}", request.getBounds(), e); + boundsX = boundsY = boundsWidth = boundsHeight = null; + } } } From a5efaf30095d8e9e261e54e723ba07396e1cce39 Mon Sep 17 00:00:00 2001 From: Anton Arhipov Date: Wed, 29 Oct 2025 13:34:25 +0200 Subject: [PATCH 06/20] Update app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java safe handling of bounds Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/security/WatermarkController.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index e43a2d9f4..a8e90d633 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -645,10 +645,16 @@ public class WatermarkController { if (request.getBounds() != null && !request.getBounds().isEmpty()) { String[] boundsParts = request.getBounds().split(","); if (boundsParts.length == 4) { - boundsX = Float.parseFloat(boundsParts[0].trim()); - boundsY = Float.parseFloat(boundsParts[1].trim()); - boundsWidth = Float.parseFloat(boundsParts[2].trim()); - boundsHeight = Float.parseFloat(boundsParts[3].trim()); + try { + boundsX = Float.parseFloat(boundsParts[0].trim()); + boundsY = Float.parseFloat(boundsParts[1].trim()); + boundsWidth = Float.parseFloat(boundsParts[2].trim()); + boundsHeight = Float.parseFloat(boundsParts[3].trim()); + } catch (NumberFormatException e) { + log.warn("Invalid bounds format: {}", request.getBounds(), e); + return ResponseEntity.badRequest() + .body(WebResponseUtils.error("Invalid bounds format. Expected four comma-separated numbers.")); + } } } From 72022c41ecf8049185a4ff80493add1d4cc11a9a Mon Sep 17 00:00:00 2001 From: antonarhipov Date: Wed, 29 Oct 2025 14:06:25 +0200 Subject: [PATCH 07/20] fix the renderTextWithPerLetterVariations invocation after Copilot's commit --- .../SPDF/controller/api/security/WatermarkController.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java index a8e90d633..a530f5a25 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java @@ -492,7 +492,6 @@ public class WatermarkController { if (perLetterFont || perLetterColor || perLetterSize || perLetterOrientation) { renderTextWithPerLetterVariations( contentStream, - document, textLines, wmFont, wmFontSize, @@ -652,8 +651,6 @@ public class WatermarkController { boundsHeight = Float.parseFloat(boundsParts[3].trim()); } catch (NumberFormatException e) { log.warn("Invalid bounds format: {}", request.getBounds(), e); - return ResponseEntity.badRequest() - .body(WebResponseUtils.error("Invalid bounds format. Expected four comma-separated numbers.")); } } } From 2fb895dd3b30137d9592a3bc70e2260d0a638dee Mon Sep 17 00:00:00 2001 From: antonarhipov Date: Thu, 30 Oct 2025 13:56:29 +0200 Subject: [PATCH 08/20] fix for the checkboxes highlight on hover --- .../templates/security/add-watermark.html | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/app/core/src/main/resources/templates/security/add-watermark.html b/app/core/src/main/resources/templates/security/add-watermark.html index 9f73bd591..1d6adf615 100644 --- a/app/core/src/main/resources/templates/security/add-watermark.html +++ b/app/core/src/main/resources/templates/security/add-watermark.html @@ -144,8 +144,8 @@
- - + +
Enable random placement instead of grid layout
@@ -185,8 +185,8 @@
- - + + For images only
@@ -200,8 +200,8 @@
- - + +
Enable random font selection for text watermarks
@@ -234,8 +234,8 @@
- - + +
@@ -245,8 +245,8 @@
- - + +