diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java index 8858c99bf..5592532b8 100644 --- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java @@ -14,6 +14,8 @@ public final class RegexPatternUtils { private static final String WHITESPACE_REGEX = "\\s++"; private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$"; + private static final Pattern COLOR_PATTERN = + Pattern.compile("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$"); private RegexPatternUtils() { super(); @@ -310,6 +312,10 @@ public final class RegexPatternUtils { return EXTENSION_REGEX; } + public static Pattern getColorPattern() { + return COLOR_PATTERN; + } + /** Pattern for extracting non-numeric characters */ public Pattern getNumericExtractionPattern() { return getPattern("\\D"); 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..d0ad36310 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/WatermarkRandomizer.java @@ -0,0 +1,359 @@ +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 { + + public static final Color[] PALETTE = + new Color[] { + 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 + }; + + 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 page. + * + * @param pageWidth Width of the page + * @param pageHeight Height of the page + * @param watermarkWidth Width of the watermark + * @param watermarkHeight Height of the watermark + * @return Array with [x, y] coordinates + */ + public float[] generateRandomPosition( + float pageWidth, float pageHeight, float watermarkWidth, float watermarkHeight) { + + // Calculate available space + float maxX = Math.max(0, pageWidth - watermarkWidth); + float maxY = Math.max(0, pageHeight - watermarkHeight); + + // Generate random position within page + float x = random.nextFloat() * maxX; + float y = random.nextFloat() * maxY; + + return new float[] {x, y}; + } + + /** + * Generates multiple random positions with collision detection to ensure minimum spacing. + * + *

This method uses collision detection to ensure that each watermark maintains minimum + * separation from all previously placed watermarks. If a valid position cannot be found after + * multiple attempts, the method will still return the requested count but some positions may + * not satisfy spacing constraints. + * + * @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 widthSpacer Horizontal spacing between watermarks (minimum separation) + * @param heightSpacer Vertical spacing between watermarks (minimum separation) + * @param count Number of positions to generate + * @return List of [x, y] coordinate arrays + */ + public List generateRandomPositions( + float pageWidth, + float pageHeight, + float watermarkWidth, + float watermarkHeight, + int widthSpacer, + int heightSpacer, + int count) { + + List positions = new ArrayList<>(); + float maxX = Math.max(0, pageWidth - watermarkWidth); + float maxY = Math.max(0, pageHeight - watermarkHeight); + + // Prevent infinite loops with a maximum attempts limit + int maxAttempts = count * 10; + int attempts = 0; + + while (positions.size() < count && attempts < maxAttempts) { + // Generate a random position + float x = random.nextFloat() * maxX; + float y = random.nextFloat() * maxY; + + // Check if position maintains minimum spacing from existing positions + boolean validPosition = true; + + if (widthSpacer > 0 || heightSpacer > 0) { + for (float[] existing : positions) { + float dx = Math.abs(x - existing[0]); + float dy = Math.abs(y - existing[1]); + + // Check if the new position overlaps or violates spacing constraints + // Two watermarks violate spacing if their bounding boxes (including spacers) + // overlap + if (dx < (watermarkWidth + widthSpacer) + && dy < (watermarkHeight + heightSpacer)) { + validPosition = false; + break; + } + } + } + + if (validPosition) { + positions.add(new float[] {x, y}); + } + + attempts++; + } + + // If we couldn't generate enough positions with spacing constraints, + // fill remaining positions without spacing constraints to meet the requested count + while (positions.size() < count) { + float x = random.nextFloat() * maxX; + float y = random.nextFloat() * maxY; + positions.add(new float[] {x, y}); + } + + return positions; + } + + /** + * 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 automatic margins to keep watermarks within page boundaries + float maxX = Math.max(0, pageWidth - watermarkWidth); + float maxY = Math.max(0, pageHeight - watermarkHeight); + + // Calculate how many rows and columns can fit within the page + // Note: watermarkWidth/Height are the actual watermark dimensions (not including spacers) + // We need to account for the spacing between watermarks when calculating grid capacity + int maxRows = (int) Math.floor(maxY / (watermarkHeight + heightSpacer)); + int maxCols = (int) Math.floor(maxX / (watermarkWidth + widthSpacer)); + + if (count == 0) { + // Unlimited grid: fill page using spacer-based grid + // Ensure watermarks stay within visible area + for (int i = 0; i < maxRows; i++) { + for (int j = 0; j < maxCols; j++) { + float x = j * (watermarkWidth + widthSpacer); + float y = i * (watermarkHeight + heightSpacer); + // Clamp to ensure within bounds + x = Math.min(x, maxX); + y = Math.min(y, maxY); + positions.add(new float[] {x, y}); + } + } + } else { + // Limited count: distribute evenly across page + // Calculate optimal distribution based on page aspect ratio + // Don't use spacer-based limits; instead ensure positions fit within maxX/maxY + int cols = (int) Math.ceil(Math.sqrt(count * pageWidth / pageHeight)); + int rows = (int) Math.ceil((double) count / cols); + + // Calculate spacing to distribute watermarks evenly within the visible area + // Account for watermark dimensions to prevent overflow at edges + float xSpacing = (cols > 1) ? maxX / (cols - 1) : 0; + float ySpacing = (rows > 1) ? maxY / (rows - 1) : 0; + + int generated = 0; + for (int i = 0; i < rows && generated < count; i++) { + for (int j = 0; j < cols && generated < count; j++) { + float x = j * xSpacing; + float y = i * ySpacing; + // Clamp to ensure within bounds + x = Math.min(x, maxX); + y = Math.min(y, maxY); + 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) { + 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 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) { + // Limit to requested count + int actualCount = Math.min(colorCount, PALETTE.length); + actualCount = Math.max(1, actualCount); // At least 1 + + return PALETTE[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..4bb82ec91 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/WatermarkRandomizerTest.java @@ -0,0 +1,1044 @@ +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); + float[] pos2 = randomizer2.generateRandomPosition(800f, 600f, 100f, 50f); + + assertArrayEquals(pos1, pos2, "Same seed should produce same position"); + } + + @Test + @DisplayName("Should keep watermark within page bounds") + void testGenerateRandomPositionWithinBounds() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + 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); + + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] <= pageWidth - watermarkWidth, + "X position should not exceed page width minus watermark width"); + assertTrue( + pos[1] <= pageHeight - watermarkHeight, + "Y position should not exceed page height minus watermark height"); + } + } + + @Test + @DisplayName("Should handle small watermarks on large pages") + void testGenerateRandomPositionSmallWatermark() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 800f; + float pageHeight = 600f; + float watermarkWidth = 50f; + float watermarkHeight = 30f; + + for (int i = 0; i < 10; i++) { + float[] pos = + randomizer.generateRandomPosition( + pageWidth, pageHeight, watermarkWidth, watermarkHeight); + + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] <= pageWidth - watermarkWidth, + "X position should not exceed page width minus watermark width"); + assertTrue( + pos[1] <= pageHeight - watermarkHeight, + "Y position should not exceed page height minus watermark height"); + } + } + + @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 = 150; + + 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 keep watermarks within page boundaries for unlimited grid") + void testGenerateGridPositionsUnlimitedWithinBounds() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 800f; + float pageHeight = 600f; + float watermarkWidth = 100f; + float watermarkHeight = 80f; + int widthSpacer = 50; + int heightSpacer = 40; + + List positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + 0); + + assertNotNull(positions, "Positions should not be null"); + assertFalse(positions.isEmpty(), "Should generate at least one position"); + + // Verify all watermarks fit within page boundaries + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] + watermarkWidth <= pageWidth, + String.format( + "Watermark right edge (%.2f) should not exceed page width (%.2f)", + pos[0] + watermarkWidth, pageWidth)); + assertTrue( + pos[1] + watermarkHeight <= pageHeight, + String.format( + "Watermark top edge (%.2f) should not exceed page height (%.2f)", + pos[1] + watermarkHeight, pageHeight)); + } + } + + @Test + @DisplayName("Should handle large watermarks on small pages") + void testGenerateGridPositionsLargeWatermark() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 300f; + float pageHeight = 200f; + float watermarkWidth = 150f; + float watermarkHeight = 100f; + int widthSpacer = 20; + int heightSpacer = 15; + int count = 4; + + List positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertNotNull(positions, "Positions should not be null"); + + // Verify all watermarks fit within page boundaries + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] + watermarkWidth <= pageWidth, + String.format( + "Watermark right edge (%.2f) should not exceed page width (%.2f)", + pos[0] + watermarkWidth, pageWidth)); + assertTrue( + pos[1] + watermarkHeight <= pageHeight, + String.format( + "Watermark top edge (%.2f) should not exceed page height (%.2f)", + pos[1] + watermarkHeight, pageHeight)); + } + } + + @Test + @DisplayName("Should handle edge case with zero spacers") + void testGenerateGridPositionsZeroSpacers() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 600f; + float pageHeight = 400f; + float watermarkWidth = 100f; + float watermarkHeight = 80f; + int widthSpacer = 0; + int heightSpacer = 0; + int count = 6; + + List positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertNotNull(positions, "Positions should not be null"); + + // Verify all watermarks fit within page boundaries + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] + watermarkWidth <= pageWidth, + String.format( + "Watermark right edge (%.2f) should not exceed page width (%.2f)", + pos[0] + watermarkWidth, pageWidth)); + assertTrue( + pos[1] + watermarkHeight <= pageHeight, + String.format( + "Watermark top edge (%.2f) should not exceed page height (%.2f)", + pos[1] + watermarkHeight, pageHeight)); + } + } + + @Test + @DisplayName("Should handle edge case with large spacers") + void testGenerateGridPositionsLargeSpacers() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 1000f; + float pageHeight = 800f; + float watermarkWidth = 80f; + float watermarkHeight = 60f; + int widthSpacer = 200; + int heightSpacer = 150; + int count = 0; // Unlimited + + List positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertNotNull(positions, "Positions should not be null"); + + // Verify all watermarks fit within page boundaries + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] + watermarkWidth <= pageWidth, + String.format( + "Watermark right edge (%.2f) should not exceed page width (%.2f)", + pos[0] + watermarkWidth, pageWidth)); + assertTrue( + pos[1] + watermarkHeight <= pageHeight, + String.format( + "Watermark top edge (%.2f) should not exceed page height (%.2f)", + pos[1] + watermarkHeight, pageHeight)); + } + } + + @Test + @DisplayName("Should handle watermark exactly fitting page dimensions") + void testGenerateGridPositionsExactFit() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 400f; + float pageHeight = 300f; + float watermarkWidth = 400f; + float watermarkHeight = 300f; + int widthSpacer = 0; + int heightSpacer = 0; + int count = 1; + + List positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertNotNull(positions, "Positions should not be null"); + assertEquals(1, positions.size(), "Should generate exactly one position"); + + float[] pos = positions.get(0); + assertEquals(0f, pos[0], 0.01f, "X position should be 0"); + assertEquals(0f, pos[1], 0.01f, "Y position should be 0"); + assertTrue( + pos[0] + watermarkWidth <= pageWidth, + "Watermark right edge should not exceed page width"); + assertTrue( + pos[1] + watermarkHeight <= pageHeight, + "Watermark top edge should not exceed page height"); + } + } + + @Nested + @DisplayName("Collision Detection Tests") + class CollisionDetectionTests { + + @Test + @DisplayName("Should generate deterministic positions with seed and collision detection") + void testGenerateRandomPositionsWithSeed() { + WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED); + WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED); + + List positions1 = + randomizer1.generateRandomPositions(800f, 600f, 100f, 50f, 50, 30, 5); + List positions2 = + randomizer2.generateRandomPositions(800f, 600f, 100f, 50f, 50, 30, 5); + + assertEquals( + positions1.size(), + positions2.size(), + "Same seed should produce same number of positions"); + for (int i = 0; i < positions1.size(); i++) { + assertArrayEquals( + positions1.get(i), + positions2.get(i), + "Same seed should produce same positions"); + } + } + + @Test + @DisplayName("Should maintain minimum spacing between watermarks") + void testGenerateRandomPositionsMaintainsSpacing() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 1000f; + float pageHeight = 800f; + float watermarkWidth = 100f; + float watermarkHeight = 80f; + int widthSpacer = 50; + int heightSpacer = 40; + int count = 10; + + List positions = + randomizer.generateRandomPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertEquals(count, positions.size(), "Should generate requested count of positions"); + + // Verify all positions are within bounds + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] <= pageWidth - watermarkWidth, + "X position should not exceed page width minus watermark width"); + assertTrue( + pos[1] <= pageHeight - watermarkHeight, + "Y position should not exceed page height minus watermark height"); + } + + // Verify spacing between positions + for (int i = 0; i < positions.size(); i++) { + for (int j = i + 1; j < positions.size(); j++) { + float[] pos1 = positions.get(i); + float[] pos2 = positions.get(j); + float dx = Math.abs(pos1[0] - pos2[0]); + float dy = Math.abs(pos1[1] - pos2[1]); + + // If positions overlap in both dimensions, they violate spacing + boolean overlapsX = dx < (watermarkWidth + widthSpacer); + boolean overlapsY = dy < (watermarkHeight + heightSpacer); + + // At least one dimension should have sufficient spacing + assertFalse( + overlapsX && overlapsY, + String.format( + "Positions %d and %d violate spacing constraints: dx=%.2f (min=%.2f), dy=%.2f (min=%.2f)", + i, + j, + dx, + watermarkWidth + widthSpacer, + dy, + watermarkHeight + heightSpacer)); + } + } + } + + @Test + @DisplayName("Should allow any placement with zero spacers") + void testGenerateRandomPositionsWithZeroSpacers() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 800f; + float pageHeight = 600f; + float watermarkWidth = 100f; + float watermarkHeight = 80f; + int count = 15; + + List positions = + randomizer.generateRandomPositions( + pageWidth, pageHeight, watermarkWidth, watermarkHeight, 0, 0, count); + + assertEquals(count, positions.size(), "Should generate requested count of positions"); + + // Verify all positions are within bounds + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] <= pageWidth - watermarkWidth, + "X position should not exceed page width minus watermark width"); + assertTrue( + pos[1] <= pageHeight - watermarkHeight, + "Y position should not exceed page height minus watermark height"); + } + } + + @Test + @DisplayName("Should handle high density placement gracefully") + void testGenerateRandomPositionsHighDensity() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 500f; + float pageHeight = 400f; + float watermarkWidth = 80f; + float watermarkHeight = 60f; + int widthSpacer = 40; + int heightSpacer = 30; + int count = 50; // Request more than can fit with spacing + + List positions = + randomizer.generateRandomPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertEquals( + count, + positions.size(), + "Should still generate requested count even if spacing cannot be maintained"); + + // Verify all positions are within bounds + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] <= pageWidth - watermarkWidth, + "X position should not exceed page width minus watermark width"); + assertTrue( + pos[1] <= pageHeight - watermarkHeight, + "Y position should not exceed page height minus watermark height"); + } + } + + @Test + @DisplayName("Should handle large spacers that exceed page space") + void testGenerateRandomPositionsLargeSpacers() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 400f; + float pageHeight = 300f; + float watermarkWidth = 100f; + float watermarkHeight = 80f; + int widthSpacer = 500; // Exceeds page width + int heightSpacer = 400; // Exceeds page height + int count = 5; + + List positions = + randomizer.generateRandomPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertEquals(count, positions.size(), "Should generate requested count of positions"); + + // Verify all positions are within bounds + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] <= pageWidth - watermarkWidth, + "X position should not exceed page width minus watermark width"); + assertTrue( + pos[1] <= pageHeight - watermarkHeight, + "Y position should not exceed page height minus watermark height"); + } + } + + @Test + @DisplayName("Should handle single watermark request") + void testGenerateRandomPositionsSingleWatermark() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 800f; + float pageHeight = 600f; + float watermarkWidth = 100f; + float watermarkHeight = 80f; + int widthSpacer = 50; + int heightSpacer = 40; + + List positions = + randomizer.generateRandomPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + 1); + + assertEquals(1, positions.size(), "Should generate exactly one position"); + + float[] pos = positions.get(0); + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] <= pageWidth - watermarkWidth, + "X position should not exceed page width minus watermark width"); + assertTrue( + pos[1] <= pageHeight - watermarkHeight, + "Y position should not exceed page height minus watermark height"); + } + + @Test + @DisplayName("Should respect maximum attempts limit") + void testGenerateRandomPositionsMaxAttempts() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 200f; + float pageHeight = 150f; + float watermarkWidth = 100f; + float watermarkHeight = 75f; + int widthSpacer = 50; + int heightSpacer = 40; + int count = 20; // Request more than can possibly fit + + // This should complete without hanging due to max attempts limit + List positions = + randomizer.generateRandomPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + + assertEquals( + count, + positions.size(), + "Should generate requested count even if spacing cannot be maintained"); + } + + @Test + @DisplayName("Should handle negative spacers as zero") + void testGenerateRandomPositionsNegativeSpacers() { + WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED); + float pageWidth = 800f; + float pageHeight = 600f; + float watermarkWidth = 100f; + float watermarkHeight = 80f; + int count = 10; + + List positions = + randomizer.generateRandomPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + -10, + -20, + count); + + assertEquals(count, positions.size(), "Should generate requested count of positions"); + + // Verify all positions are within bounds + for (float[] pos : positions) { + assertTrue(pos[0] >= 0, "X position should be non-negative"); + assertTrue(pos[1] >= 0, "Y position should be non-negative"); + assertTrue( + pos[0] <= pageWidth - watermarkWidth, + "X position should not exceed page width minus watermark width"); + assertTrue( + pos[1] <= pageHeight - watermarkHeight, + "Y position should not exceed page height minus watermark height"); + } + } + } + + @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 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 = List.of(); + + 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 = List.of(); + + 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..a70aab410 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 @@ -1,5 +1,7 @@ package stirling.software.SPDF.controller.api.security; +import static stirling.software.common.util.RegexPatternUtils.getColorPattern; + import java.awt.*; import java.awt.image.BufferedImage; import java.beans.PropertyEditorSupport; @@ -15,13 +17,18 @@ import org.apache.commons.io.IOUtils; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.PDStream; import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; import org.apache.pdfbox.util.Matrix; import org.springframework.core.io.ClassPathResource; import org.springframework.http.MediaType; @@ -38,14 +45,13 @@ 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.WebResponseUtils; +import stirling.software.common.util.*; +@Slf4j @RestController @RequestMapping("/api/v1/security") @Tag(name = "Security", description = "Security APIs") @@ -66,6 +72,162 @@ 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) { + log.error("Opacity must be between 0.0 and 1.0, but got: {}", opacity); + throw ExceptionUtils.createIllegalArgumentException( + "error.opacityOutOfRange", // TODO + "Opacity must be between 0.0 and 1.0, but got: {0}", + opacity); + } + + // Validate rotation range: rotationMin <= rotationMax + Float rotationMin = request.getRotationMin(); + Float rotationMax = request.getRotationMax(); + if (rotationMin != null && rotationMax != null && rotationMin > rotationMax) { + log.error( + "Rotation minimum ({}) must be less than or equal to rotation maximum ({})", + rotationMin, + rotationMax); + throw ExceptionUtils.createIllegalArgumentException( + "error.rotationRangeInvalid", // TODO + "Rotation minimum ({0}) must be less than or equal to rotation maximum ({1})", + rotationMin, + rotationMax); + } + + // Validate font size range: fontSizeMin <= fontSizeMax + Float fontSizeMin = request.getFontSizeMin(); + Float fontSizeMax = request.getFontSizeMax(); + if (fontSizeMin != null && fontSizeMax != null && fontSizeMin > fontSizeMax) { + log.error( + "Font size minimum ({}) must be less than or equal to font size maximum ({})", + fontSizeMin, + fontSizeMax); + throw ExceptionUtils.createIllegalArgumentException( + "error.fontSizeRangeInvalid", // TODO + "Font size minimum ({0}) must be less than or equal to font size maximum ({1})", + fontSizeMin, + fontSizeMax); + } + + // Validate 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 (!getColorPattern().matcher(customColor).matches()) { + log.error( + "Invalid color format: {}. Expected hex format like #RRGGBB or #RRGGBBAA", + customColor); + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidColorFormat", // TODO + "Invalid color format: {0}. Expected hex format like #RRGGBB or #RRGGBBAA", + customColor); + } + } + + // Validate mirroring probability bounds (0.0 - 1.0) + Float mirroringProbability = request.getMirroringProbability(); + if (mirroringProbability != null + && (mirroringProbability < 0.0f || mirroringProbability > 1.0f)) { + log.error( + "Mirroring probability must be between 0.0 and 1.0, but got: {}", + mirroringProbability); + throw ExceptionUtils.createIllegalArgumentException( + "error.mirroringProbabilityOutOfRange", // TODO + "Mirroring probability must be between 0.0 and 1.0, but got: {0}", + mirroringProbability); + } + + // Validate watermark type + String watermarkType = request.getWatermarkType(); + if (watermarkType == null + || (!watermarkType.equalsIgnoreCase("text") + && !watermarkType.equalsIgnoreCase("image"))) { + log.error("Watermark type must be 'text' or 'image', but got: {}", watermarkType); + throw ExceptionUtils.createIllegalArgumentException( + "error.unsupportedWatermarkType", // TODO + "Watermark type must be ''text'' or ''image'', but got: {0}", // single quotes + // must be escaped + watermarkType); + } + + // Validate text watermark has text + if ("text".equalsIgnoreCase(watermarkType)) { + String watermarkText = request.getWatermarkText(); + if (watermarkText == null || watermarkText.trim().isEmpty()) { + log.error("Watermark text is required when watermark type is 'text'"); + throw ExceptionUtils.createIllegalArgumentException( + "error.watermarkTextRequired", // TODO + "Watermark text is required when watermark type is 'text'"); + } + } + + // Validate image watermark has image + if ("image".equalsIgnoreCase(watermarkType)) { + MultipartFile watermarkImage = request.getWatermarkImage(); + if (watermarkImage == null || watermarkImage.isEmpty()) { + log.error("Watermark image is required when watermark type is 'image'"); + throw ExceptionUtils.createIllegalArgumentException( + "error.watermarkImageRequired", // TODO + "Watermark image is required when watermark type is 'image'"); + } + + // Validate image type - only allow common image formats + String contentType = watermarkImage.getContentType(); + String originalFilename = watermarkImage.getOriginalFilename(); + if (contentType != null && !isSupportedImageType(contentType)) { + log.error( + "Unsupported image type: {}. Supported types: PNG, JPG, JPEG, GIF, BMP", + contentType); + throw ExceptionUtils.createIllegalArgumentException( + "error.unsupportedContentType", // TODO + "Unsupported image type: {0}. Supported types: PNG, JPG, JPEG, GIF, BMP", + contentType); + } + + // Additional check based on file extension + if (originalFilename != null && !hasSupportedImageExtension(originalFilename)) { + log.error( + "Unsupported image file extension in: {}. Supported extensions: .png, .jpg, .jpeg, .gif, .bmp", + originalFilename); + throw ExceptionUtils.createIllegalArgumentException( + "error.unsupportedImageFileType", // TODO + "Unsupported image file extension in: {0}. Supported extensions: .png, .jpg, .jpeg, .gif, .bmp", + originalFilename); + } + } + + 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,73 +236,85 @@ 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 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); - // Create a page in the document + // Create a PDFormXObject to cache watermarks (Solution 5: PDFormXObject caching) + PDFormXObject watermarkForm = null; + + // Iterate through pages for (PDPage page : document.getPages()) { - // Get the page's content stream + // Create the watermark form on the first page + if (watermarkForm == null) { + // Create form XObject with the same dimensions as the page + // Following the pattern from CertSignController + PDRectangle pageBox = page.getMediaBox(); + PDStream stream = new PDStream(document); + watermarkForm = new PDFormXObject(stream); + watermarkForm.setResources(new PDResources()); + watermarkForm.setFormType(1); + watermarkForm.setBBox(new PDRectangle(pageBox.getWidth(), pageBox.getHeight())); + + // Create appearance stream for the form + PDAppearanceStream appearanceStream = + new PDAppearanceStream(watermarkForm.getCOSObject()); + + // Create a content stream for the appearance + PDPageContentStream formStream = + new PDPageContentStream(document, appearanceStream); + + // Set transparency in the form + PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState(); + graphicsState.setNonStrokingAlphaConstant(request.getOpacity()); + formStream.setGraphicsStateParameters(graphicsState); + + // Render watermarks into the form + if ("text".equalsIgnoreCase(watermarkType)) { + addTextWatermark(formStream, document, page, request, randomizer); + } else if ("image".equalsIgnoreCase(watermarkType)) { + addImageWatermark(formStream, document, page, request, randomizer); + } + + // Close the form stream + formStream.close(); + } + + // Draw the cached form on the current page PDPageContentStream contentStream = new PDPageContentStream( document, page, PDPageContentStream.AppendMode.APPEND, true, true); - - // Set transparency - PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState(); - graphicsState.setNonStrokingAlphaConstant(opacity); - contentStream.setGraphicsStateParameters(graphicsState); - - if ("text".equalsIgnoreCase(watermarkType)) { - addTextWatermark( - contentStream, - watermarkText, - document, - page, - rotation, - widthSpacer, - heightSpacer, - fontSize, - alphabet, - customColor); - } else if ("image".equalsIgnoreCase(watermarkType)) { - addImageWatermark( - contentStream, - watermarkImage, - document, - page, - rotation, - widthSpacer, - heightSpacer, - fontSize); - } - - // Close the content stream + contentStream.drawForm(watermarkForm); contentStream.close(); } @@ -158,19 +332,60 @@ 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; + + // 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; + + String resourceDir = switch (alphabet) { case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf"; case "japanese" -> "static/fonts/Meiryo.ttf"; @@ -183,6 +398,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 +408,184 @@ 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; - // Calculating the number of rows and columns. + // Calculate approximate watermark dimensions for positioning + // Estimate width based on average character width (more accurate than fixed 100) + float avgCharWidth = fontSize * 0.6f; // Approximate average character width + float maxLineWidth = 0; + for (String line : textLines) { + float lineWidth = line.length() * avgCharWidth; + if (lineWidth > maxLineWidth) { + maxLineWidth = lineWidth; + } + } + float watermarkWidth = maxLineWidth; + float watermarkHeight = fontSize * textLines.length; - int watermarkRows = (int) (pageHeight / newWatermarkHeight + 1); - int watermarkCols = (int) (pageWidth / newWatermarkWidth + 1); + if (randomPosition) { + // Generate random positions with collision detection + positions = + randomizer.generateRandomPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + } else { + // Generate grid positions (backward compatible) + positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + watermarkWidth, + watermarkHeight, + widthSpacer, + heightSpacer, + count); + } + + // Define available fonts for random selection + java.util.List availableFonts = + java.util.Arrays.asList( + "Helvetica", + "Times-Roman", + "Courier", + "Helvetica-Bold", + "Times-Bold", + "Courier-Bold"); + + // Render each watermark instance + for (float[] pos : positions) { + float x = pos[0]; + float y = pos[1]; + + // 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, + 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); + + // Calculate actual watermark dimensions for center-point rotation + float actualMaxLineWidth = 0; + for (String textLine : textLines) { + float lineWidth = wmFont.getStringWidth(textLine) * wmFontSize / 1000; + if (lineWidth > actualMaxLineWidth) { + actualMaxLineWidth = lineWidth; + } + } + float actualWatermarkWidth = actualMaxLineWidth; + float actualWatermarkHeight = wmFontSize * textLines.length; + + // Calculate center point of the watermark + float centerX = x + actualWatermarkWidth / 2; + float centerY = y + actualWatermarkHeight / 2; + + // Apply rotation around center point + // To rotate around center, we need to: + // 1. Calculate offset from center to bottom-left corner + // 2. Rotate that offset + // 3. Add rotated offset to center to get final position + double rotationRad = Math.toRadians(wmRotation); + double cos = Math.cos(rotationRad); + double sin = Math.sin(rotationRad); + + // Offset from center to bottom-left corner (where text starts) + float offsetX = -actualWatermarkWidth / 2; + float offsetY = -actualWatermarkHeight / 2; + + // Apply rotation to the offset + float rotatedOffsetX = (float) (offsetX * cos - offsetY * sin); + float rotatedOffsetY = (float) (offsetX * sin + offsetY * cos); + + // Final position where text should start + float finalX = centerX + rotatedOffsetX; + float finalY = centerY + rotatedOffsetY; - // Add the text watermark - for (int i = 0; i <= watermarkRows; i++) { - for (int j = 0; j <= watermarkCols; j++) { contentStream.beginText(); contentStream.setTextMatrix( Matrix.getRotateInstance( - (float) Math.toRadians(rotation), - j * newWatermarkWidth, - i * newWatermarkHeight)); + (float) Math.toRadians(wmRotation), finalX, finalY)); - 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 +593,168 @@ public class WatermarkController { } } + private void renderTextWithPerLetterVariations( + PDPageContentStream contentStream, + 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 { + + // Convert base rotation to radians for trigonometric calculations + double baseRotationRad = Math.toRadians(baseRotation); + double cosBase = Math.cos(baseRotationRad); + double sinBase = Math.sin(baseRotationRad); + + float currentLineOffset = 0; // Vertical offset for each line + + for (String line : textLines) { + float currentCharOffset = 0; // Horizontal offset along the baseline for current line + + 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; + + // Calculate per-letter rotation: base rotation + additional per-letter variation + float additionalRotation = + perLetterOrientation + ? randomizer.generatePerLetterRotationInRange( + perLetterOrientationMin, perLetterOrientationMax) + : 0f; + float letterRotation = baseRotation + additionalRotation; + + // 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); + } + } + + // Calculate letter dimensions for center-point rotation + float charWidth = letterFont.getStringWidth(charStr) * letterSize / 1000; + float charHeight = letterSize; // Approximate height as font size + + // Calculate the position of this letter along the rotated baseline + // Transform from local coordinates (along baseline) to page coordinates + float localX = currentCharOffset; + float localY = -currentLineOffset; // Negative because text lines go downward + + // Apply rotation transformation to position the letter on the rotated baseline + float transformedX = (float) (startX + localX * cosBase - localY * sinBase); + float transformedY = (float) (startY + localX * sinBase + localY * cosBase); + + // Calculate letter's center point on the rotated baseline + float letterCenterX = transformedX + charWidth / 2; + float letterCenterY = transformedY + charHeight / 2; + + // Apply rotation around letter center + // Calculate offset from center to bottom-left corner + double letterRotationRad = Math.toRadians(letterRotation); + double cosLetter = Math.cos(letterRotationRad); + double sinLetter = Math.sin(letterRotationRad); + + float offsetX = -charWidth / 2; + float offsetY = -charHeight / 2; + + // Rotate the offset + float rotatedOffsetX = (float) (offsetX * cosLetter - offsetY * sinLetter); + float rotatedOffsetY = (float) (offsetX * sinLetter + offsetY * cosLetter); + + // Final position where letter should be rendered + float finalLetterX = letterCenterX + rotatedOffsetX; + float finalLetterY = letterCenterY + rotatedOffsetY; + + // Set font and color + contentStream.setFont(letterFont, letterSize); + contentStream.setNonStrokingColor(letterColor); + + // Render the character at the transformed position with combined rotation + contentStream.beginText(); + contentStream.setTextMatrix( + Matrix.getRotateInstance( + (float) Math.toRadians(letterRotation), + finalLetterX, + finalLetterY)); + contentStream.showText(charStr); + contentStream.endText(); + + // Advance position along the baseline for the next character + currentCharOffset += charWidth; + } + + // Move to next line (advance vertically in local coordinates) + currentLineOffset += 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; + // 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 +762,102 @@ 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); + // Determine positions based on a randomPosition flag + java.util.List positions; + if (randomPosition) { + // Generate random positions with collision detection + positions = + randomizer.generateRandomPositions( + pageWidth, + pageHeight, + desiredPhysicalWidth, + desiredPhysicalHeight, + widthSpacer, + heightSpacer, + count); + } else { + // Generate grid positions (backward compatible) + positions = + randomizer.generateGridPositions( + pageWidth, + pageHeight, + desiredPhysicalWidth, + desiredPhysicalHeight, + widthSpacer, + heightSpacer, + count); + } - // Save the graphics state - contentStream.saveGraphicsState(); + // Render each watermark instance + for (float[] pos : positions) { + float x = pos[0]; + float y = pos[1]; - // 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)); + // Determine rotation for this watermark + float wmRotation = randomizer.generateRandomRotation(rotationMin, rotationMax); - // Draw the image and restore the graphics state - contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight); - contentStream.restoreGraphicsState(); + // 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 88d3ba45b..247456b6d 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,11 @@ 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 lombok.Data; import lombok.EqualsAndHashCode; @@ -54,4 +59,137 @@ 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 = "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/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index e4c6378f1..23787d503 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -1583,6 +1583,61 @@ watermark.selectText.10=Convert PDF to PDF-Image watermark.submit=Add Watermark watermark.type.1=Text watermark.type.2=Image +watermark.advanced.title=Advanced Options +watermark.advanced.count.label=Number of Watermarks +watermark.advanced.count.help=Number of watermark instances per page (1-1000) +watermark.advanced.position.label=Random Positioning +watermark.advanced.position.help=Enable random placement instead of grid layout +watermark.advanced.rotation.mode.label=Rotation Mode +watermark.advanced.rotation.mode.fixed=Fixed Angle +watermark.advanced.rotation.mode.range=Random Range +watermark.advanced.rotation.min.label=Rotation Min (degrees) +watermark.advanced.rotation.max.label=Rotation Max (degrees) +watermark.advanced.rotation.range.help=Random angle between min and max +watermark.advanced.rotation.range.error=Minimum must be less than or equal to maximum +watermark.advanced.mirroring.label=Random Mirroring +watermark.advanced.mirroring.help=For images only +watermark.advanced.mirroring.probability.label=Mirroring Probability +watermark.advanced.mirroring.probability.help=Probability of mirroring (0.0-1.0) +watermark.advanced.font.random.label=Random Font +watermark.advanced.font.random.help=Enable random font selection for text watermarks +watermark.advanced.font.name.label=Font Name +watermark.advanced.font.name.help=Leave empty for default font +watermark.advanced.font.size.mode.label=Font Size Mode +watermark.advanced.font.size.mode.fixed=Fixed Size +watermark.advanced.font.size.mode.range=Random Range +watermark.advanced.font.size.min.label=Font Size Min +watermark.advanced.font.size.max.label=Font Size Max +watermark.advanced.font.size.range.help=Random size between min and max +watermark.advanced.font.size.range.error=Minimum must be less than or equal to maximum +watermark.advanced.color.random.label=Random Color +watermark.advanced.perLetter.title=Per-Letter Variations +watermark.advanced.perLetter.font.label=Vary Font per Letter +watermark.advanced.perLetter.font.count.label=Number of Fonts +watermark.advanced.perLetter.font.count.help=Number of fonts to randomly select from (1-20, default: 2) +watermark.advanced.perLetter.color.label=Vary Color per Letter +watermark.advanced.perLetter.color.count.label=Number of Colors +watermark.advanced.perLetter.color.count.help=Number of colors to randomly select from (1-12, default: 4) +watermark.advanced.perLetter.size.label=Vary Size per Letter +watermark.advanced.perLetter.size.min.label=Size Min +watermark.advanced.perLetter.size.max.label=Size Max +watermark.advanced.perLetter.size.help=Font size range (1-500, default: 10-100) +watermark.advanced.perLetter.size.error=Minimum must be less than or equal to maximum +watermark.advanced.perLetter.orientation.label=Vary Orientation per Letter +watermark.advanced.perLetter.orientation.min.label=Angle Min (degrees) +watermark.advanced.perLetter.orientation.max.label=Angle Max (degrees) +watermark.advanced.perLetter.orientation.help=Rotation angle range (-360 to 360, default: 0-360) +watermark.advanced.perLetter.orientation.error=Minimum must be less than or equal to maximum +watermark.advanced.perLetter.help=Apply variations at the letter level for dynamic appearance +watermark.advanced.shading.random.label=Random Shading +watermark.advanced.shading.label=Shading Style +watermark.advanced.shading.none=None +watermark.advanced.shading.light=Light +watermark.advanced.shading.dark=Dark +watermark.advanced.image.scale.label=Image Scale Factor +watermark.advanced.image.scale.help=Scale factor (1.0 = original size, 0.1-10.0) +watermark.advanced.seed.label=Random Seed (optional) +watermark.advanced.seed.help=For deterministic randomness (testing) #Change permissions diff --git a/app/core/src/main/resources/messages_en_US.properties b/app/core/src/main/resources/messages_en_US.properties index 287955226..102234b5c 100644 --- a/app/core/src/main/resources/messages_en_US.properties +++ b/app/core/src/main/resources/messages_en_US.properties @@ -1267,6 +1267,8 @@ flatten.flattenOnlyForms=Flatten only forms flatten.renderDpi=Rendering DPI (optional, recommended 150 DPI): flatten.renderDpi.help=Leave blank to use the system default. Higher DPI sharpens output but increases processing time and file size. flatten.submit=Flatten +flatten.renderDpi=Rendering DPI (optional, recommended 150 DPI): +flatten.renderDpi.help=Leave blank to use the system default. Higher DPI sharpens output but increases processing time and file size. #ScannerImageSplit @@ -1568,6 +1570,61 @@ watermark.selectText.10=Convert PDF to PDF-Image watermark.submit=Add Watermark watermark.type.1=Text watermark.type.2=Image +watermark.advanced.title=Advanced Options +watermark.advanced.count.label=Number of Watermarks +watermark.advanced.count.help=Number of watermark instances per page (1-1000) +watermark.advanced.position.label=Random Positioning +watermark.advanced.position.help=Enable random placement instead of grid layout +watermark.advanced.rotation.mode.label=Rotation Mode +watermark.advanced.rotation.mode.fixed=Fixed Angle +watermark.advanced.rotation.mode.range=Random Range +watermark.advanced.rotation.min.label=Rotation Min (degrees) +watermark.advanced.rotation.max.label=Rotation Max (degrees) +watermark.advanced.rotation.range.help=Random angle between min and max +watermark.advanced.rotation.range.error=Minimum must be less than or equal to maximum +watermark.advanced.mirroring.label=Random Mirroring +watermark.advanced.mirroring.help=For images only +watermark.advanced.mirroring.probability.label=Mirroring Probability +watermark.advanced.mirroring.probability.help=Probability of mirroring (0.0-1.0) +watermark.advanced.font.random.label=Random Font +watermark.advanced.font.random.help=Enable random font selection for text watermarks +watermark.advanced.font.name.label=Font Name +watermark.advanced.font.name.help=Leave empty for default font +watermark.advanced.font.size.mode.label=Font Size Mode +watermark.advanced.font.size.mode.fixed=Fixed Size +watermark.advanced.font.size.mode.range=Random Range +watermark.advanced.font.size.min.label=Font Size Min +watermark.advanced.font.size.max.label=Font Size Max +watermark.advanced.font.size.range.help=Random size between min and max +watermark.advanced.font.size.range.error=Minimum must be less than or equal to maximum +watermark.advanced.color.random.label=Random Color +watermark.advanced.perLetter.title=Per-Letter Variations +watermark.advanced.perLetter.font.label=Vary Font per Letter +watermark.advanced.perLetter.font.count.label=Number of Fonts +watermark.advanced.perLetter.font.count.help=Number of fonts to randomly select from (1-20, default: 2) +watermark.advanced.perLetter.color.label=Vary Color per Letter +watermark.advanced.perLetter.color.count.label=Number of Colors +watermark.advanced.perLetter.color.count.help=Number of colors to randomly select from (1-12, default: 4) +watermark.advanced.perLetter.size.label=Vary Size per Letter +watermark.advanced.perLetter.size.min.label=Size Min +watermark.advanced.perLetter.size.max.label=Size Max +watermark.advanced.perLetter.size.help=Font size range (1-500, default: 10-100) +watermark.advanced.perLetter.size.error=Minimum must be less than or equal to maximum +watermark.advanced.perLetter.orientation.label=Vary Orientation per Letter +watermark.advanced.perLetter.orientation.min.label=Angle Min (degrees) +watermark.advanced.perLetter.orientation.max.label=Angle Max (degrees) +watermark.advanced.perLetter.orientation.help=Rotation angle range (-360 to 360, default: 0-360) +watermark.advanced.perLetter.orientation.error=Minimum must be less than or equal to maximum +watermark.advanced.perLetter.help=Apply variations at the letter level for dynamic appearance +watermark.advanced.shading.random.label=Random Shading +watermark.advanced.shading.label=Shading Style +watermark.advanced.shading.none=None +watermark.advanced.shading.light=Light +watermark.advanced.shading.dark=Dark +watermark.advanced.image.scale.label=Image Scale Factor +watermark.advanced.image.scale.help=Scale factor (1.0 = original size, 0.1-10.0) +watermark.advanced.seed.label=Random Seed (optional) +watermark.advanced.seed.help=For deterministic randomness (testing) #Change permissions 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..3fafdf107 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,55 +95,248 @@ appendPercentageSymbol(); -
- - -
-
- - -
-
- - -
-
- -
- -
- +
+ + +
+
+ + +
+ +
+ +
+ +
+ +
+ + +
+ +
+ +
+ + + Number of watermark instances per page (1-1000) +
+ + +
+
+ + +
+ Enable random placement instead of grid layout +
+ + +
+ + +
+ + + + +
+
+ + + 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) +
+
+
-
- - -
-
- -
- +
+ + +
+
+ +
+
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..1d016e573 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerIntegrationTest.java @@ -0,0 +1,705 @@ +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.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"); + } + } + + @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.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..641c86e09 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkValidationTest.java @@ -0,0 +1,978 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.Set; + +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 jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +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; + + @BeforeEach + void setUp() throws Exception { + request = new AddWatermarkRequest(); + MockMultipartFile 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 { + + private Validator validator; + + @BeforeEach + void setUpValidator() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + @DisplayName("Should reject count below minimum of 1") + void testCountMinimum() { + // Arrange + request.setCount(0); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch(v -> v.getPropertyPath().toString().equals("count")), + "Should have violation on 'count' field"); + } + + @Test + @DisplayName("Should accept valid count value") + void testCountValid() { + // Arrange + request.setCount(5); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertTrue( + violations.stream() + .noneMatch(v -> v.getPropertyPath().toString().equals("count")), + "Should have no violations on 'count' field"); + } + + @Test + @DisplayName("Should reject count above maximum of 1000") + void testCountMaximum() { + // Arrange + request.setCount(1001); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch(v -> v.getPropertyPath().toString().equals("count")), + "Should have violation on 'count' field"); + } + + @Test + @DisplayName("Should reject rotationMin below -360") + void testRotationMinBelowBound() { + // Arrange + request.setRotationMin(-361f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch(v -> v.getPropertyPath().toString().equals("rotationMin")), + "Should have violation on 'rotationMin' field"); + } + + @Test + @DisplayName("Should reject rotationMax above 360") + void testRotationMaxAboveBound() { + // Arrange + request.setRotationMax(361f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch(v -> v.getPropertyPath().toString().equals("rotationMax")), + "Should have violation on 'rotationMax' field"); + } + + @Test + @DisplayName("Should accept valid rotation values") + void testRotationValid() { + // Arrange + request.setRotationMin(-180f); + request.setRotationMax(180f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertTrue( + violations.stream() + .noneMatch( + v -> + v.getPropertyPath().toString().equals("rotationMin") + || v.getPropertyPath() + .toString() + .equals("rotationMax")), + "Should have no violations on rotation fields"); + } + + @Test + @DisplayName("Should reject fontSizeMin below 1.0") + void testFontSizeMinBelowBound() { + // Arrange + request.setFontSizeMin(0.5f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch(v -> v.getPropertyPath().toString().equals("fontSizeMin")), + "Should have violation on 'fontSizeMin' field"); + } + + @Test + @DisplayName("Should reject fontSizeMax above 500.0") + void testFontSizeMaxAboveBound() { + // Arrange + request.setFontSizeMax(501f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch(v -> v.getPropertyPath().toString().equals("fontSizeMax")), + "Should have violation on 'fontSizeMax' field"); + } + + @Test + @DisplayName("Should accept valid font size values") + void testFontSizeValid() { + // Arrange + request.setFontSizeMin(10f); + request.setFontSizeMax(100f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertTrue( + violations.stream() + .noneMatch( + v -> + v.getPropertyPath().toString().equals("fontSizeMin") + || v.getPropertyPath() + .toString() + .equals("fontSizeMax")), + "Should have no violations on font size fields"); + } + + @Test + @DisplayName("Should reject perLetterFontCount below 1") + void testPerLetterFontCountMinimum() { + // Arrange + request.setPerLetterFontCount(0); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch( + v -> + v.getPropertyPath() + .toString() + .equals("perLetterFontCount")), + "Should have violation on 'perLetterFontCount' field"); + } + + @Test + @DisplayName("Should reject perLetterFontCount above 20") + void testPerLetterFontCountMaximum() { + // Arrange + request.setPerLetterFontCount(21); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch( + v -> + v.getPropertyPath() + .toString() + .equals("perLetterFontCount")), + "Should have violation on 'perLetterFontCount' field"); + } + + @Test + @DisplayName("Should accept valid perLetterFontCount value") + void testPerLetterFontCountValid() { + // Arrange + request.setPerLetterFontCount(5); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertTrue( + violations.stream() + .noneMatch( + v -> + v.getPropertyPath() + .toString() + .equals("perLetterFontCount")), + "Should have no violations on 'perLetterFontCount' field"); + } + + @Test + @DisplayName("Should reject perLetterColorCount below 1") + void testPerLetterColorCountMinimum() { + // Arrange + request.setPerLetterColorCount(0); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch( + v -> + v.getPropertyPath() + .toString() + .equals("perLetterColorCount")), + "Should have violation on 'perLetterColorCount' field"); + } + + @Test + @DisplayName("Should reject perLetterColorCount above 20") + void testPerLetterColorCountMaximum() { + // Arrange + request.setPerLetterColorCount(21); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch( + v -> + v.getPropertyPath() + .toString() + .equals("perLetterColorCount")), + "Should have violation on 'perLetterColorCount' field"); + } + + @Test + @DisplayName("Should accept valid perLetterColorCount value") + void testPerLetterColorCountValid() { + // Arrange + request.setPerLetterColorCount(4); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertTrue( + violations.stream() + .noneMatch( + v -> + v.getPropertyPath() + .toString() + .equals("perLetterColorCount")), + "Should have no violations on 'perLetterColorCount' field"); + } + + @Test + @DisplayName("Should reject imageScale below 0.1") + void testImageScaleBelowBound() { + // Arrange + request.setImageScale(0.05f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch(v -> v.getPropertyPath().toString().equals("imageScale")), + "Should have violation on 'imageScale' field"); + } + + @Test + @DisplayName("Should reject imageScale above 10.0") + void testImageScaleAboveBound() { + // Arrange + request.setImageScale(11f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertFalse(violations.isEmpty(), "Should have validation errors"); + assertTrue( + violations.stream() + .anyMatch(v -> v.getPropertyPath().toString().equals("imageScale")), + "Should have violation on 'imageScale' field"); + } + + @Test + @DisplayName("Should accept valid imageScale value") + void testImageScaleValid() { + // Arrange + request.setImageScale(1.5f); + + // Act + Set> violations = validator.validate(request); + + // Assert + assertTrue( + violations.stream() + .noneMatch(v -> v.getPropertyPath().toString().equals("imageScale")), + "Should have no violations on 'imageScale' field"); + } + } +}