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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Random angle between min and max
+
+ Minimum must be less than or equal to maximum
+
+
+
+
+
+
+
+
+ For images only
+
+
+
+
+
+
+ Probability of mirroring (0.0-1.0)
+
+
+
+
+
+
+
+
+
Enable random font selection for text watermarks
+
+
+
+
+
+ Leave empty for default font
+
+
+
+
+
+
+
+
+
+
+
+
+
Random size between min and max
+
+ Minimum must be less than or equal to maximum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Number of fonts to randomly select from (1-20, default: 2)
+
+
+
+
+
+
+
+
+
+
+ Number of colors to randomly select from (1-12, default: 4)
+
+
+
+
+
+
+
+
+
+
+
+
+
Font size range (1-500, default: 10-100)
+
+ Minimum must be less than or equal to maximum
+
+
+
+
+
+
+
+
+
+
+
+
+
Rotation angle range (-360 to 360, default: 0-360)
+
+ Minimum must be less than or equal to maximum
+
+
+
+
Apply variations at the letter level for dynamic appearance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Scale factor (1.0 = original size, 0.1-10.0)
+
+
+
+
+
+ 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");
+ }
+ }
+}