Watermark functionality enhancements for #3421

- Grid layout and random positioning of watermarks per page
- Random orientation
- Per watermark font randomization of color and size
- Text watermark per-letter randomization of font, color, size, and orientation
- Sharing
- Random mirroring of image watermarks
- Add `WatermarkRandomizer` utility and enhance watermark request handling with new validations and randomization features
This commit is contained in:
antonarhipov 2025-10-29 13:03:31 +02:00
parent db1d0138dd
commit db142af139
7 changed files with 3595 additions and 152 deletions

View File

@ -0,0 +1,315 @@
package stirling.software.common.util;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* Utility class for generating randomized watermark attributes with deterministic (seedable)
* randomness. Supports position, rotation, mirroring, font selection, size, color, and shading.
*/
public class WatermarkRandomizer {
private final Random random;
/**
* Creates a WatermarkRandomizer with an optional seed for deterministic randomness.
*
* @param seed Optional seed value; if null, uses non-deterministic randomness
*/
public WatermarkRandomizer(Long seed) {
this.random = (seed != null) ? new Random(seed) : new Random();
}
/**
* Generates a random position within the given bounds and margins.
*
* @param pageWidth Width of the page
* @param pageHeight Height of the page
* @param watermarkWidth Width of the watermark
* @param watermarkHeight Height of the watermark
* @param margin Minimum margin from page edges
* @param boundsX Optional bounding box X coordinate (null for full page)
* @param boundsY Optional bounding box Y coordinate (null for full page)
* @param boundsWidth Optional bounding box width (null for full page)
* @param boundsHeight Optional bounding box height (null for full page)
* @return Array with [x, y] coordinates
*/
public float[] generateRandomPosition(
float pageWidth,
float pageHeight,
float watermarkWidth,
float watermarkHeight,
float margin,
Float boundsX,
Float boundsY,
Float boundsWidth,
Float boundsHeight) {
// Determine effective bounds
float effectiveX = (boundsX != null) ? boundsX : margin;
float effectiveY = (boundsY != null) ? boundsY : margin;
float effectiveWidth = (boundsWidth != null) ? boundsWidth : (pageWidth - 2 * margin);
float effectiveHeight = (boundsHeight != null) ? boundsHeight : (pageHeight - 2 * margin);
// Calculate available space
float maxX = Math.max(effectiveX, effectiveX + effectiveWidth - watermarkWidth);
float maxY = Math.max(effectiveY, effectiveY + effectiveHeight - watermarkHeight);
float minX = effectiveX;
float minY = effectiveY;
// Generate random position within bounds
float x = minX + random.nextFloat() * Math.max(0, maxX - minX);
float y = minY + random.nextFloat() * Math.max(0, maxY - minY);
return new float[] {x, y};
}
/**
* Generates a list of fixed grid positions based on spacers.
*
* @param pageWidth Width of the page
* @param pageHeight Height of the page
* @param watermarkWidth Width of the watermark (includes spacing)
* @param watermarkHeight Height of the watermark (includes spacing)
* @param widthSpacer Horizontal spacing between watermarks
* @param heightSpacer Vertical spacing between watermarks
* @param count Maximum number of watermarks (0 for unlimited grid)
* @return List of [x, y] coordinate arrays
*/
public List<float[]> generateGridPositions(
float pageWidth,
float pageHeight,
float watermarkWidth,
float watermarkHeight,
int widthSpacer,
int heightSpacer,
int count) {
List<float[]> positions = new ArrayList<>();
// Calculate how many rows and columns can fit on the page based on spacers
int maxRows = (int) (pageHeight / (watermarkHeight + heightSpacer) + 1);
int maxCols = (int) (pageWidth / (watermarkWidth + widthSpacer) + 1);
if (count == 0) {
// Unlimited grid: fill entire page using spacer-based grid
for (int i = 0; i <= maxRows; i++) {
for (int j = 0; j <= maxCols; j++) {
float x = j * (watermarkWidth + widthSpacer);
float y = i * (watermarkHeight + heightSpacer);
positions.add(new float[] {x, y});
}
}
} else {
// Limited count: distribute evenly across the page using spacer-based grid
// Calculate optimal distribution
int cols =
Math.min((int) Math.ceil(Math.sqrt(count * pageWidth / pageHeight)), maxCols);
int rows = Math.min((int) Math.ceil((double) count / cols), maxRows);
// Calculate step sizes to distribute watermarks evenly across available grid
int colStep = Math.max(1, maxCols / cols);
int rowStep = Math.max(1, maxRows / rows);
int generated = 0;
for (int i = 0; i < maxRows && generated < count; i += rowStep) {
for (int j = 0; j < maxCols && generated < count; j += colStep) {
float x = j * (watermarkWidth + widthSpacer);
float y = i * (watermarkHeight + heightSpacer);
positions.add(new float[] {x, y});
generated++;
}
}
}
return positions;
}
/**
* Generates a random rotation angle within the specified range.
*
* @param rotationMin Minimum rotation angle in degrees
* @param rotationMax Maximum rotation angle in degrees
* @return Random rotation angle in degrees
*/
public float generateRandomRotation(float rotationMin, float rotationMax) {
if (rotationMin == rotationMax) {
return rotationMin;
}
return rotationMin + random.nextFloat() * (rotationMax - rotationMin);
}
/**
* Determines whether to mirror based on probability.
*
* @param probability Probability of mirroring (0.0 to 1.0)
* @return true if should mirror, false otherwise
*/
public boolean shouldMirror(float probability) {
return random.nextFloat() < probability;
}
/**
* Selects a random font from the available font list.
*
* @param availableFonts List of available font names
* @return Random font name from the list
*/
public String selectRandomFont(List<String> availableFonts) {
if (availableFonts == null || availableFonts.isEmpty()) {
return "default";
}
return availableFonts.get(random.nextInt(availableFonts.size()));
}
/**
* Generates a random font size within the specified range.
*
* @param fontSizeMin Minimum font size
* @param fontSizeMax Maximum font size
* @return Random font size
*/
public float generateRandomFontSize(float fontSizeMin, float fontSizeMax) {
if (fontSizeMin == fontSizeMax) {
return fontSizeMin;
}
return fontSizeMin + random.nextFloat() * (fontSizeMax - fontSizeMin);
}
/**
* Generates a random color from a predefined palette or full spectrum.
*
* @param usePalette If true, uses a predefined palette; otherwise generates random RGB
* @return Random Color object
*/
public Color generateRandomColor(boolean usePalette) {
if (usePalette) {
// Predefined palette of common colors
Color[] palette = {
Color.BLACK,
Color.DARK_GRAY,
Color.GRAY,
Color.LIGHT_GRAY,
Color.RED,
Color.BLUE,
Color.GREEN,
Color.ORANGE,
Color.MAGENTA,
Color.CYAN,
Color.PINK,
Color.YELLOW
};
return palette[random.nextInt(palette.length)];
} else {
// Generate random RGB color
return new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256));
}
}
/**
* Selects a random shading style from available options.
*
* @param availableShadings List of available shading styles
* @return Random shading style
*/
public String selectRandomShading(List<String> availableShadings) {
if (availableShadings == null || availableShadings.isEmpty()) {
return "none";
}
return availableShadings.get(random.nextInt(availableShadings.size()));
}
/**
* Generates a random rotation for per-letter orientation within a safe range.
*
* @param maxRotation Maximum rotation angle in degrees (applied as +/- range)
* @return Random rotation angle in degrees
*/
public float generatePerLetterRotation(float maxRotation) {
return -maxRotation + random.nextFloat() * (2 * maxRotation);
}
/**
* Generates a random color from a limited palette.
*
* @param colorCount Number of colors to select from (1-12 from predefined palette)
* @return Random Color object from the limited palette
*/
public Color generateRandomColorFromPalette(int colorCount) {
// Predefined palette of common colors
Color[] fullPalette = {
Color.BLACK,
Color.DARK_GRAY,
Color.GRAY,
Color.LIGHT_GRAY,
Color.RED,
Color.BLUE,
Color.GREEN,
Color.ORANGE,
Color.MAGENTA,
Color.CYAN,
Color.PINK,
Color.YELLOW
};
// Limit to requested count
int actualCount = Math.min(colorCount, fullPalette.length);
actualCount = Math.max(1, actualCount); // At least 1
return fullPalette[random.nextInt(actualCount)];
}
/**
* Selects a random font from a limited list of available fonts.
*
* @param fontCount Number of fonts to select from
* @return Random font name
*/
public String selectRandomFontFromCount(int fontCount) {
// Predefined list of common PDF fonts
String[] availableFonts = {
"Helvetica",
"Times-Roman",
"Courier",
"Helvetica-Bold",
"Times-Bold",
"Courier-Bold",
"Helvetica-Oblique",
"Times-Italic",
"Courier-Oblique",
"Symbol",
"ZapfDingbats"
};
// Limit to requested count
int actualCount = Math.min(fontCount, availableFonts.length);
actualCount = Math.max(1, actualCount); // At least 1
return availableFonts[random.nextInt(actualCount)];
}
/**
* Generates a random rotation for per-letter orientation within specified range.
*
* @param minRotation Minimum rotation angle in degrees
* @param maxRotation Maximum rotation angle in degrees
* @return Random rotation angle in degrees
*/
public float generatePerLetterRotationInRange(float minRotation, float maxRotation) {
if (minRotation == maxRotation) {
return minRotation;
}
return minRotation + random.nextFloat() * (maxRotation - minRotation);
}
/**
* Gets the underlying Random instance for advanced use cases.
*
* @return The Random instance
*/
public Random getRandom() {
return random;
}
}

View File

@ -0,0 +1,603 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.Color;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@DisplayName("WatermarkRandomizer Unit Tests")
class WatermarkRandomizerTest {
private static final long TEST_SEED = 12345L;
@Nested
@DisplayName("Position Randomization Tests")
class PositionRandomizationTests {
@Test
@DisplayName("Should generate deterministic random position with seed")
void testGenerateRandomPositionWithSeed() {
WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED);
WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED);
float[] pos1 =
randomizer1.generateRandomPosition(
800f, 600f, 100f, 50f, 10f, null, null, null, null);
float[] pos2 =
randomizer2.generateRandomPosition(
800f, 600f, 100f, 50f, 10f, null, null, null, null);
assertArrayEquals(pos1, pos2, "Same seed should produce same position");
}
@Test
@DisplayName("Should respect margin constraints")
void testGenerateRandomPositionWithMargin() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float margin = 20f;
float pageWidth = 800f;
float pageHeight = 600f;
float watermarkWidth = 100f;
float watermarkHeight = 50f;
for (int i = 0; i < 10; i++) {
float[] pos =
randomizer.generateRandomPosition(
pageWidth,
pageHeight,
watermarkWidth,
watermarkHeight,
margin,
null,
null,
null,
null);
assertTrue(pos[0] >= margin, "X position should respect margin");
assertTrue(pos[1] >= margin, "Y position should respect margin");
assertTrue(
pos[0] <= pageWidth - margin - watermarkWidth,
"X position should not exceed page width minus margin");
assertTrue(
pos[1] <= pageHeight - margin - watermarkHeight,
"Y position should not exceed page height minus margin");
}
}
@Test
@DisplayName("Should respect custom bounds")
void testGenerateRandomPositionWithBounds() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float boundsX = 100f;
float boundsY = 100f;
float boundsWidth = 400f;
float boundsHeight = 300f;
float watermarkWidth = 50f;
float watermarkHeight = 30f;
for (int i = 0; i < 10; i++) {
float[] pos =
randomizer.generateRandomPosition(
800f,
600f,
watermarkWidth,
watermarkHeight,
10f,
boundsX,
boundsY,
boundsWidth,
boundsHeight);
assertTrue(pos[0] >= boundsX, "X position should be within bounds");
assertTrue(pos[1] >= boundsY, "Y position should be within bounds");
assertTrue(
pos[0] <= boundsX + boundsWidth - watermarkWidth,
"X position should not exceed bounds");
assertTrue(
pos[1] <= boundsY + boundsHeight - watermarkHeight,
"Y position should not exceed bounds");
}
}
@Test
@DisplayName("Should generate grid positions correctly")
void testGenerateGridPositions() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float pageWidth = 800f;
float pageHeight = 600f;
float watermarkWidth = 100f;
float watermarkHeight = 100f;
int widthSpacer = 50;
int heightSpacer = 50;
int count = 5;
List<float[]> positions =
randomizer.generateGridPositions(
pageWidth,
pageHeight,
watermarkWidth,
watermarkHeight,
widthSpacer,
heightSpacer,
count);
assertNotNull(positions, "Positions should not be null");
assertEquals(count, positions.size(), "Should generate requested count of positions");
// Verify positions are within page bounds
for (float[] pos : positions) {
assertTrue(pos[0] >= 0 && pos[0] <= pageWidth, "X position within page width");
assertTrue(pos[1] >= 0 && pos[1] <= pageHeight, "Y position within page height");
}
}
@Test
@DisplayName("Should generate unlimited grid when count is 0")
void testGenerateGridPositionsUnlimited() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float pageWidth = 400f;
float pageHeight = 300f;
float watermarkWidth = 100f;
float watermarkHeight = 100f;
List<float[]> positions =
randomizer.generateGridPositions(
pageWidth, pageHeight, watermarkWidth, watermarkHeight, 50, 50, 0);
assertNotNull(positions, "Positions should not be null");
assertTrue(positions.size() > 0, "Should generate at least one position");
}
}
@Nested
@DisplayName("Rotation Randomization Tests")
class RotationRandomizationTests {
@Test
@DisplayName("Should generate deterministic rotation with seed")
void testGenerateRandomRotationWithSeed() {
WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED);
WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED);
float rotation1 = randomizer1.generateRandomRotation(0f, 360f);
float rotation2 = randomizer2.generateRandomRotation(0f, 360f);
assertEquals(rotation1, rotation2, "Same seed should produce same rotation");
}
@Test
@DisplayName("Should generate rotation within range")
void testGenerateRandomRotationInRange() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float minRotation = -45f;
float maxRotation = 45f;
for (int i = 0; i < 20; i++) {
float rotation = randomizer.generateRandomRotation(minRotation, maxRotation);
assertTrue(
rotation >= minRotation && rotation <= maxRotation,
"Rotation should be within specified range");
}
}
@Test
@DisplayName("Should return fixed rotation when min equals max")
void testGenerateRandomRotationFixed() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float fixedRotation = 30f;
float rotation = randomizer.generateRandomRotation(fixedRotation, fixedRotation);
assertEquals(
fixedRotation, rotation, "Should return fixed rotation when min equals max");
}
@Test
@DisplayName("Should generate per-letter rotation within symmetric range")
void testGeneratePerLetterRotation() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float maxRotation = 30f;
for (int i = 0; i < 20; i++) {
float rotation = randomizer.generatePerLetterRotation(maxRotation);
assertTrue(
rotation >= -maxRotation && rotation <= maxRotation,
"Per-letter rotation should be within +/- maxRotation");
}
}
@Test
@DisplayName("Should generate per-letter rotation in specified range")
void testGeneratePerLetterRotationInRange() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float minRotation = -15f;
float maxRotation = 45f;
for (int i = 0; i < 20; i++) {
float rotation =
randomizer.generatePerLetterRotationInRange(minRotation, maxRotation);
assertTrue(
rotation >= minRotation && rotation <= maxRotation,
"Per-letter rotation should be within specified range");
}
}
@Test
@DisplayName("Should return fixed per-letter rotation when min equals max")
void testGeneratePerLetterRotationInRangeFixed() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float fixedRotation = 20f;
float rotation =
randomizer.generatePerLetterRotationInRange(fixedRotation, fixedRotation);
assertEquals(
fixedRotation, rotation, "Should return fixed rotation when min equals max");
}
}
@Nested
@DisplayName("Mirroring Randomization Tests")
class MirroringRandomizationTests {
@Test
@DisplayName("Should generate deterministic mirroring decision with seed")
void testShouldMirrorWithSeed() {
WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED);
WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED);
boolean mirror1 = randomizer1.shouldMirror(0.5f);
boolean mirror2 = randomizer2.shouldMirror(0.5f);
assertEquals(mirror1, mirror2, "Same seed should produce same mirroring decision");
}
@Test
@DisplayName("Should always mirror with probability 1.0")
void testShouldMirrorAlways() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
for (int i = 0; i < 10; i++) {
assertTrue(
randomizer.shouldMirror(1.0f), "Should always mirror with probability 1.0");
}
}
@Test
@DisplayName("Should never mirror with probability 0.0")
void testShouldMirrorNever() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
for (int i = 0; i < 10; i++) {
assertFalse(
randomizer.shouldMirror(0.0f), "Should never mirror with probability 0.0");
}
}
@Test
@DisplayName("Should mirror approximately 50% of the time with probability 0.5")
void testShouldMirrorProbability() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
int mirrorCount = 0;
int iterations = 100;
for (int i = 0; i < iterations; i++) {
if (randomizer.shouldMirror(0.5f)) {
mirrorCount++;
}
}
// Allow some variance (30-70%)
assertTrue(
mirrorCount >= 30 && mirrorCount <= 70,
"Should mirror approximately 50% of the time");
}
}
@Nested
@DisplayName("Font Randomization Tests")
class FontRandomizationTests {
@Test
@DisplayName("Should select deterministic font with seed")
void testSelectRandomFontWithSeed() {
WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED);
WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED);
List<String> 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<String> 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<String> fonts = Arrays.asList();
String font = randomizer.selectRandomFont(fonts);
assertEquals("default", font, "Should return 'default' when list is empty");
}
@Test
@DisplayName("Should return default when font list is null")
void testSelectRandomFontNull() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
String font = randomizer.selectRandomFont(null);
assertEquals("default", font, "Should return 'default' when list is null");
}
@Test
@DisplayName("Should select font from count")
void testSelectRandomFontFromCount() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
int fontCount = 5;
for (int i = 0; i < 10; i++) {
String font = randomizer.selectRandomFontFromCount(fontCount);
assertNotNull(font, "Selected font should not be null");
assertFalse(font.isEmpty(), "Selected font should not be empty");
}
}
@Test
@DisplayName("Should handle font count exceeding available fonts")
void testSelectRandomFontFromCountExceedsAvailable() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
int fontCount = 100; // More than available
String font = randomizer.selectRandomFontFromCount(fontCount);
assertNotNull(font, "Should still return a valid font");
}
}
@Nested
@DisplayName("Font Size Randomization Tests")
class FontSizeRandomizationTests {
@Test
@DisplayName("Should generate deterministic font size with seed")
void testGenerateRandomFontSizeWithSeed() {
WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED);
WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED);
float size1 = randomizer1.generateRandomFontSize(10f, 50f);
float size2 = randomizer2.generateRandomFontSize(10f, 50f);
assertEquals(size1, size2, "Same seed should produce same font size");
}
@Test
@DisplayName("Should generate font size within range")
void testGenerateRandomFontSizeInRange() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float minSize = 10f;
float maxSize = 50f;
for (int i = 0; i < 20; i++) {
float size = randomizer.generateRandomFontSize(minSize, maxSize);
assertTrue(
size >= minSize && size <= maxSize,
"Font size should be within specified range");
}
}
@Test
@DisplayName("Should return fixed font size when min equals max")
void testGenerateRandomFontSizeFixed() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
float fixedSize = 30f;
float size = randomizer.generateRandomFontSize(fixedSize, fixedSize);
assertEquals(fixedSize, size, "Should return fixed size when min equals max");
}
}
@Nested
@DisplayName("Color Randomization Tests")
class ColorRandomizationTests {
@Test
@DisplayName("Should generate deterministic color with seed")
void testGenerateRandomColorWithSeed() {
WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED);
WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED);
Color color1 = randomizer1.generateRandomColor(false);
Color color2 = randomizer2.generateRandomColor(false);
assertEquals(color1, color2, "Same seed should produce same color");
}
@Test
@DisplayName("Should generate color from palette")
void testGenerateRandomColorFromPalette() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
Color[] expectedPalette = {
Color.BLACK,
Color.DARK_GRAY,
Color.GRAY,
Color.LIGHT_GRAY,
Color.RED,
Color.BLUE,
Color.GREEN,
Color.ORANGE,
Color.MAGENTA,
Color.CYAN,
Color.PINK,
Color.YELLOW
};
for (int i = 0; i < 20; i++) {
Color color = randomizer.generateRandomColor(true);
boolean inPalette = false;
for (Color paletteColor : expectedPalette) {
if (color.equals(paletteColor)) {
inPalette = true;
break;
}
}
assertTrue(inPalette, "Color should be from predefined palette");
}
}
@Test
@DisplayName("Should generate RGB color when not using palette")
void testGenerateRandomColorRGB() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
for (int i = 0; i < 10; i++) {
Color color = randomizer.generateRandomColor(false);
assertNotNull(color, "Color should not be null");
assertTrue(color.getRed() >= 0 && color.getRed() <= 255, "Red component valid");
assertTrue(
color.getGreen() >= 0 && color.getGreen() <= 255, "Green component valid");
assertTrue(color.getBlue() >= 0 && color.getBlue() <= 255, "Blue component valid");
}
}
@Test
@DisplayName("Should generate color from limited palette")
void testGenerateRandomColorFromPaletteWithCount() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
int colorCount = 4;
for (int i = 0; i < 10; i++) {
Color color = randomizer.generateRandomColorFromPalette(colorCount);
assertNotNull(color, "Color should not be null");
}
}
@Test
@DisplayName("Should handle color count exceeding palette size")
void testGenerateRandomColorFromPaletteExceedsSize() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
int colorCount = 100; // More than palette size
Color color = randomizer.generateRandomColorFromPalette(colorCount);
assertNotNull(color, "Should still return a valid color");
}
@Test
@DisplayName("Should handle color count of 1")
void testGenerateRandomColorFromPaletteCountOne() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
Color color = randomizer.generateRandomColorFromPalette(1);
assertEquals(Color.BLACK, color, "Should return first color in palette");
}
}
@Nested
@DisplayName("Shading Randomization Tests")
class ShadingRandomizationTests {
@Test
@DisplayName("Should select deterministic shading with seed")
void testSelectRandomShadingWithSeed() {
WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED);
WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED);
List<String> 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<String> 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<String> shadings = Arrays.asList();
String shading = randomizer.selectRandomShading(shadings);
assertEquals("none", shading, "Should return 'none' when list is empty");
}
@Test
@DisplayName("Should return 'none' when shading list is null")
void testSelectRandomShadingNull() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
String shading = randomizer.selectRandomShading(null);
assertEquals("none", shading, "Should return 'none' when list is null");
}
}
@Nested
@DisplayName("Random Instance Tests")
class RandomInstanceTests {
@Test
@DisplayName("Should return non-null Random instance")
void testGetRandom() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(TEST_SEED);
assertNotNull(randomizer.getRandom(), "Random instance should not be null");
}
@Test
@DisplayName("Should use seeded Random for deterministic behavior")
void testSeededRandomBehavior() {
WatermarkRandomizer randomizer1 = new WatermarkRandomizer(TEST_SEED);
WatermarkRandomizer randomizer2 = new WatermarkRandomizer(TEST_SEED);
int value1 = randomizer1.getRandom().nextInt(100);
int value2 = randomizer2.getRandom().nextInt(100);
assertEquals(value1, value2, "Seeded Random should produce same values");
}
@Test
@DisplayName("Should use non-deterministic Random when seed is null")
void testNonSeededRandomBehavior() {
WatermarkRandomizer randomizer = new WatermarkRandomizer(null);
assertNotNull(
randomizer.getRandom(), "Random instance should not be null even without seed");
}
}
}

View File

@ -38,14 +38,17 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WatermarkRandomizer;
import stirling.software.common.util.WebResponseUtils;
@Slf4j
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@ -66,6 +69,149 @@ public class WatermarkController {
});
}
/**
* Validates watermark request parameters and enforces safety caps. Throws
* IllegalArgumentException with descriptive messages for validation failures.
*/
private void validateWatermarkRequest(AddWatermarkRequest request) {
// Validate opacity bounds (0.0 - 1.0)
float opacity = request.getOpacity();
if (opacity < 0.0f || opacity > 1.0f) {
String errorMsg =
String.format("Opacity must be between 0.0 and 1.0, but got: %.2f", opacity);
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// Validate rotation range: rotationMin <= rotationMax
Float rotationMin = request.getRotationMin();
Float rotationMax = request.getRotationMax();
if (rotationMin != null && rotationMax != null && rotationMin > rotationMax) {
String errorMsg =
String.format(
"Rotation minimum (%.2f) must be less than or equal to rotation maximum (%.2f)",
rotationMin, rotationMax);
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// Validate font size range: fontSizeMin <= fontSizeMax
Float fontSizeMin = request.getFontSizeMin();
Float fontSizeMax = request.getFontSizeMax();
if (fontSizeMin != null && fontSizeMax != null && fontSizeMin > fontSizeMax) {
String errorMsg =
String.format(
"Font size minimum (%.2f) must be less than or equal to font size maximum (%.2f)",
fontSizeMin, fontSizeMax);
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// Validate a color format when not using random color
String customColor = request.getCustomColor();
Boolean randomColor = request.getRandomColor();
if (customColor != null && !Boolean.TRUE.equals(randomColor)) {
// Check if color is valid hex format (#RRGGBB or #RRGGBBAA)
if (!customColor.matches("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$")) {
String errorMsg =
String.format(
"Invalid color format: '%s'. Expected hex format like #RRGGBB or #RRGGBBAA",
customColor);
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
}
// Validate mirroring probability bounds (0.0 - 1.0)
Float mirroringProbability = request.getMirroringProbability();
if (mirroringProbability != null
&& (mirroringProbability < 0.0f || mirroringProbability > 1.0f)) {
String errorMsg =
String.format(
"Mirroring probability must be between 0.0 and 1.0, but got: %.2f",
mirroringProbability);
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// Validate watermark type
String watermarkType = request.getWatermarkType();
if (watermarkType == null
|| (!watermarkType.equalsIgnoreCase("text")
&& !watermarkType.equalsIgnoreCase("image"))) {
String errorMsg =
String.format(
"Watermark type must be 'text' or 'image', but got: '%s'",
watermarkType);
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// Validate text watermark has text
if ("text".equalsIgnoreCase(watermarkType)) {
String watermarkText = request.getWatermarkText();
if (watermarkText == null || watermarkText.trim().isEmpty()) {
String errorMsg = "Watermark text is required when watermark type is 'text'";
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
}
// Validate image watermark has image
if ("image".equalsIgnoreCase(watermarkType)) {
MultipartFile watermarkImage = request.getWatermarkImage();
if (watermarkImage == null || watermarkImage.isEmpty()) {
String errorMsg = "Watermark image is required when watermark type is 'image'";
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// Validate image type - only allow common image formats
String contentType = watermarkImage.getContentType();
String originalFilename = watermarkImage.getOriginalFilename();
if (contentType != null && !isSupportedImageType(contentType)) {
String errorMsg =
String.format(
"Unsupported image type: '%s'. Supported types: PNG, JPG, JPEG, GIF, BMP",
contentType);
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
// Additional check based on file extension
if (originalFilename != null && !hasSupportedImageExtension(originalFilename)) {
String errorMsg =
String.format(
"Unsupported image file extension in: '%s'. Supported extensions: .png, .jpg, .jpeg, .gif, .bmp",
originalFilename);
log.warn("Validation failed: {}", errorMsg);
throw new IllegalArgumentException(errorMsg);
}
}
log.debug("Watermark request validation passed");
}
/** Checks if the content type is a supported image format. */
private boolean isSupportedImageType(String contentType) {
return contentType.equals("image/png")
|| contentType.equals("image/jpeg")
|| contentType.equals("image/jpg")
|| contentType.equals("image/gif")
|| contentType.equals("image/bmp")
|| contentType.equals("image/x-ms-bmp");
}
/** Checks if the filename has a supported image extension. */
private boolean hasSupportedImageExtension(String filename) {
String lowerFilename = filename.toLowerCase();
return lowerFilename.endsWith(".png")
|| lowerFilename.endsWith(".jpg")
|| lowerFilename.endsWith(".jpeg")
|| lowerFilename.endsWith(".gif")
|| lowerFilename.endsWith(".bmp");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-watermark")
@Operation(
summary = "Add watermark to a PDF file",
@ -74,32 +220,37 @@ public class WatermarkController {
+ " watermark type (text or image), rotation, opacity, width spacer, and"
+ " height spacer. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> addWatermark(@ModelAttribute AddWatermarkRequest request)
throws IOException, Exception {
throws IOException {
MultipartFile pdfFile = request.getFileInput();
String pdfFileName = pdfFile.getOriginalFilename();
if (pdfFileName != null && (pdfFileName.contains("..") || pdfFileName.startsWith("/"))) {
log.error("Security violation: Invalid file path in pdfFile: {}", pdfFileName);
throw new SecurityException("Invalid file path in pdfFile");
}
String watermarkType = request.getWatermarkType();
String watermarkText = request.getWatermarkText();
MultipartFile watermarkImage = request.getWatermarkImage();
if (watermarkImage != null) {
String watermarkImageFileName = watermarkImage.getOriginalFilename();
if (watermarkImageFileName != null
&& (watermarkImageFileName.contains("..")
|| watermarkImageFileName.startsWith("/"))) {
log.error(
"Security violation: Invalid file path in watermarkImage: {}",
watermarkImageFileName);
throw new SecurityException("Invalid file path in watermarkImage");
}
}
String alphabet = request.getAlphabet();
float fontSize = request.getFontSize();
float rotation = request.getRotation();
float opacity = request.getOpacity();
int widthSpacer = request.getWidthSpacer();
int heightSpacer = request.getHeightSpacer();
String customColor = request.getCustomColor();
// Validate request parameters and enforce safety caps
validateWatermarkRequest(request);
// Extract new fields with defaults for backward compatibility
Integer count = (request.getCount() != null) ? request.getCount() : 1;
boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage());
// Create a randomizer with optional seed for deterministic behavior
WatermarkRandomizer randomizer = new WatermarkRandomizer(request.getSeed());
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
@ -113,31 +264,13 @@ public class WatermarkController {
// Set transparency
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(opacity);
graphicsState.setNonStrokingAlphaConstant(request.getOpacity());
contentStream.setGraphicsStateParameters(graphicsState);
if ("text".equalsIgnoreCase(watermarkType)) {
addTextWatermark(
contentStream,
watermarkText,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize,
alphabet,
customColor);
addTextWatermark(contentStream, document, page, request, randomizer);
} else if ("image".equalsIgnoreCase(watermarkType)) {
addImageWatermark(
contentStream,
watermarkImage,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize);
addImageWatermark(contentStream, document, page, request, randomizer);
}
// Close the content stream
@ -158,19 +291,73 @@ public class WatermarkController {
private void addTextWatermark(
PDPageContentStream contentStream,
String watermarkText,
PDDocument document,
PDPage page,
float rotation,
int widthSpacer,
int heightSpacer,
float fontSize,
String alphabet,
String colorString)
AddWatermarkRequest request,
WatermarkRandomizer randomizer)
throws IOException {
String resourceDir = "";
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
resourceDir =
String watermarkText = request.getWatermarkText();
String alphabet = request.getAlphabet();
String colorString = request.getCustomColor();
float rotation = request.getRotation();
int widthSpacer = request.getWidthSpacer();
int heightSpacer = request.getHeightSpacer();
float fontSize = request.getFontSize();
// Extract new fields with defaults
int count = (request.getCount() != null) ? request.getCount() : 1;
boolean randomPosition = Boolean.TRUE.equals(request.getRandomPosition());
boolean randomFont = Boolean.TRUE.equals(request.getRandomFont());
boolean randomColor = Boolean.TRUE.equals(request.getRandomColor());
boolean perLetterFont = Boolean.TRUE.equals(request.getPerLetterFont());
boolean perLetterColor = Boolean.TRUE.equals(request.getPerLetterColor());
boolean perLetterSize = Boolean.TRUE.equals(request.getPerLetterSize());
boolean perLetterOrientation = Boolean.TRUE.equals(request.getPerLetterOrientation());
boolean shadingRandom = Boolean.TRUE.equals(request.getShadingRandom());
String shading = request.getShading();
float rotationMin =
(request.getRotationMin() != null) ? request.getRotationMin() : rotation;
float rotationMax =
(request.getRotationMax() != null) ? request.getRotationMax() : rotation;
float fontSizeMin =
(request.getFontSizeMin() != null) ? request.getFontSizeMin() : fontSize;
float fontSizeMax =
(request.getFontSizeMax() != null) ? request.getFontSizeMax() : fontSize;
float margin = (request.getMargin() != null) ? request.getMargin() : 10f;
// Extract per-letter configuration with defaults
int perLetterFontCount =
(request.getPerLetterFontCount() != null) ? request.getPerLetterFontCount() : 2;
int perLetterColorCount =
(request.getPerLetterColorCount() != null) ? request.getPerLetterColorCount() : 4;
float perLetterSizeMin =
(request.getPerLetterSizeMin() != null) ? request.getPerLetterSizeMin() : 10f;
float perLetterSizeMax =
(request.getPerLetterSizeMax() != null) ? request.getPerLetterSizeMax() : 100f;
float perLetterOrientationMin =
(request.getPerLetterOrientationMin() != null)
? request.getPerLetterOrientationMin()
: 0f;
float perLetterOrientationMax =
(request.getPerLetterOrientationMax() != null)
? request.getPerLetterOrientationMax()
: 360f;
// Parse bounds if provided
Float boundsX = null, boundsY = null, boundsWidth = null, boundsHeight = null;
if (request.getBounds() != null && !request.getBounds().isEmpty()) {
String[] boundsParts = request.getBounds().split(",");
if (boundsParts.length == 4) {
boundsX = Float.parseFloat(boundsParts[0].trim());
boundsY = Float.parseFloat(boundsParts[1].trim());
boundsWidth = Float.parseFloat(boundsParts[2].trim());
boundsHeight = Float.parseFloat(boundsParts[3].trim());
}
}
String resourceDir =
switch (alphabet) {
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
case "japanese" -> "static/fonts/Meiryo.ttf";
@ -183,6 +370,8 @@ public class WatermarkController {
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
PDFont font;
try (InputStream is = classPathResource.getInputStream();
FileOutputStream os = new FileOutputStream(tempFile)) {
IOUtils.copy(is, os);
@ -191,63 +380,144 @@ public class WatermarkController {
Files.deleteIfExists(tempFile.toPath());
}
contentStream.setFont(font, fontSize);
Color redactColor;
try {
if (!colorString.startsWith("#")) {
colorString = "#" + colorString;
}
redactColor = Color.decode(colorString);
} catch (NumberFormatException e) {
redactColor = Color.LIGHT_GRAY;
}
contentStream.setNonStrokingColor(redactColor);
String[] textLines =
RegexPatternUtils.getInstance().getEscapedNewlinePattern().split(watermarkText);
float maxLineWidth = 0;
for (int i = 0; i < textLines.length; ++i) {
maxLineWidth = Math.max(maxLineWidth, font.getStringWidth(textLines[i]));
}
// Set size and location of text watermark
float watermarkWidth = widthSpacer + maxLineWidth * fontSize / 1000;
float watermarkHeight = heightSpacer + fontSize * textLines.length;
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
// Calculating the new width and height depending on the angle.
float radians = (float) Math.toRadians(rotation);
float newWatermarkWidth =
(float)
(Math.abs(watermarkWidth * Math.cos(radians))
+ Math.abs(watermarkHeight * Math.sin(radians)));
float newWatermarkHeight =
(float)
(Math.abs(watermarkWidth * Math.sin(radians))
+ Math.abs(watermarkHeight * Math.cos(radians)));
// Determine positions based on a randomPosition flag
java.util.List<float[]> positions;
if (randomPosition) {
// Generate random positions
positions = new java.util.ArrayList<>();
for (int i = 0; i < count; i++) {
// Use approximate watermark dimensions for positioning
float approxWidth = widthSpacer + 100; // Approximate
float approxHeight = heightSpacer + fontSize * textLines.length;
float[] pos =
randomizer.generateRandomPosition(
pageWidth,
pageHeight,
approxWidth,
approxHeight,
margin,
boundsX,
boundsY,
boundsWidth,
boundsHeight);
positions.add(pos);
}
} else {
// Generate grid positions (backward compatible)
float watermarkWidth = 100 * fontSize / 1000;
float watermarkHeight = fontSize * textLines.length;
positions =
randomizer.generateGridPositions(
pageWidth,
pageHeight,
watermarkWidth,
watermarkHeight,
widthSpacer,
heightSpacer,
count);
}
// Calculating the number of rows and columns.
// Define available fonts for random selection
java.util.List<String> availableFonts =
java.util.Arrays.asList(
"Helvetica",
"Times-Roman",
"Courier",
"Helvetica-Bold",
"Times-Bold",
"Courier-Bold");
int watermarkRows = (int) (pageHeight / newWatermarkHeight + 1);
int watermarkCols = (int) (pageWidth / newWatermarkWidth + 1);
// Render each watermark instance
for (float[] pos : positions) {
float x = pos[0];
float y = pos[1];
// Add the text watermark
for (int i = 0; i <= watermarkRows; i++) {
for (int j = 0; j <= watermarkCols; j++) {
// Determine the font for this watermark instance
PDFont wmFont;
if (randomFont) {
try {
String selectedFontName = randomizer.selectRandomFont(availableFonts);
wmFont = new PDType1Font(Standard14Fonts.getMappedFontName(selectedFontName));
} catch (Exception e) {
log.warn("Failed to load random font, using base font instead", e);
wmFont = font; // Fall back to the base font loaded earlier
}
} else {
wmFont = font; // Use the base font loaded from alphabet selection
}
// Determine rotation for this watermark
float wmRotation = randomizer.generateRandomRotation(rotationMin, rotationMax);
// Determine font size for this watermark
float wmFontSize = randomizer.generateRandomFontSize(fontSizeMin, fontSizeMax);
// Determine color for this watermark
Color wmColor;
if (randomColor) {
wmColor = randomizer.generateRandomColor(true);
} else {
try {
String colorStr = colorString;
if (!colorStr.startsWith("#")) {
colorStr = "#" + colorStr;
}
wmColor = Color.decode(colorStr);
} catch (Exception e) {
wmColor = Color.LIGHT_GRAY;
}
}
// Determine and apply shading style
String wmShading =
shadingRandom
? randomizer.selectRandomShading(
java.util.Arrays.asList("none", "light", "dark"))
: (shading != null ? shading : "none");
// Apply shading by adjusting color intensity
wmColor = applyShadingToColor(wmColor, wmShading);
// Render text with per-letter variations if enabled
if (perLetterFont || perLetterColor || perLetterSize || perLetterOrientation) {
renderTextWithPerLetterVariations(
contentStream,
document,
textLines,
wmFont,
wmFontSize,
wmColor,
wmRotation,
x,
y,
perLetterFont,
perLetterColor,
perLetterSize,
perLetterOrientation,
perLetterSizeMin,
perLetterSizeMax,
perLetterFontCount,
perLetterColorCount,
perLetterOrientationMin,
perLetterOrientationMax,
randomizer);
} else {
// Standard rendering without per-letter variations
contentStream.setFont(wmFont, wmFontSize);
contentStream.setNonStrokingColor(wmColor);
contentStream.beginText();
contentStream.setTextMatrix(
Matrix.getRotateInstance(
(float) Math.toRadians(rotation),
j * newWatermarkWidth,
i * newWatermarkHeight));
Matrix.getRotateInstance((float) Math.toRadians(wmRotation), x, y));
for (int k = 0; k < textLines.length; ++k) {
contentStream.showText(textLines[k]);
contentStream.newLineAtOffset(0, -fontSize);
for (String textLine : textLines) {
contentStream.showText(textLine);
contentStream.newLineAtOffset(0, -wmFontSize);
}
contentStream.endText();
@ -255,25 +525,138 @@ public class WatermarkController {
}
}
private void renderTextWithPerLetterVariations(
PDPageContentStream contentStream,
PDDocument document,
String[] textLines,
PDFont baseFont,
float baseFontSize,
Color baseColor,
float baseRotation,
float startX,
float startY,
boolean perLetterFont,
boolean perLetterColor,
boolean perLetterSize,
boolean perLetterOrientation,
float fontSizeMin,
float fontSizeMax,
int perLetterFontCount,
int perLetterColorCount,
float perLetterOrientationMin,
float perLetterOrientationMax,
WatermarkRandomizer randomizer)
throws IOException {
float currentX = startX;
float currentY = startY;
for (String line : textLines) {
currentX = startX;
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
String charStr = String.valueOf(c);
// Determine per-letter attributes
float letterSize =
perLetterSize
? randomizer.generateRandomFontSize(fontSizeMin, fontSizeMax)
: baseFontSize;
Color letterColor =
perLetterColor
? randomizer.generateRandomColorFromPalette(perLetterColorCount)
: baseColor;
float letterRotation =
perLetterOrientation
? randomizer.generatePerLetterRotationInRange(
perLetterOrientationMin, perLetterOrientationMax)
: baseRotation;
// Determine per-letter font
PDFont letterFont = baseFont;
if (perLetterFont) {
try {
String randomFontName =
randomizer.selectRandomFontFromCount(perLetterFontCount);
letterFont =
new PDType1Font(Standard14Fonts.getMappedFontName(randomFontName));
} catch (Exception e) {
// Fall back to base font if font loading fails
log.warn("Failed to load random font, using base font instead", e);
}
}
// Set font and color
contentStream.setFont(letterFont, letterSize);
contentStream.setNonStrokingColor(letterColor);
// Render the character
contentStream.beginText();
contentStream.setTextMatrix(
Matrix.getRotateInstance(
(float) Math.toRadians(letterRotation), currentX, currentY));
contentStream.showText(charStr);
contentStream.endText();
// Advance position
float charWidth = letterFont.getStringWidth(charStr) * letterSize / 1000;
currentX += charWidth;
}
currentY -= baseFontSize;
}
}
private void addImageWatermark(
PDPageContentStream contentStream,
MultipartFile watermarkImage,
PDDocument document,
PDPage page,
float rotation,
int widthSpacer,
int heightSpacer,
float fontSize)
AddWatermarkRequest request,
WatermarkRandomizer randomizer)
throws IOException {
MultipartFile watermarkImage = request.getWatermarkImage();
float rotation = request.getRotation();
int widthSpacer = request.getWidthSpacer();
int heightSpacer = request.getHeightSpacer();
float fontSize = request.getFontSize();
// Extract new fields with defaults
int count = (request.getCount() != null) ? request.getCount() : 1;
boolean randomPosition = Boolean.TRUE.equals(request.getRandomPosition());
boolean randomMirroring = Boolean.TRUE.equals(request.getRandomMirroring());
float mirroringProbability =
(request.getMirroringProbability() != null)
? request.getMirroringProbability()
: 0.5f;
float imageScale = (request.getImageScale() != null) ? request.getImageScale() : 1.0f;
float rotationMin =
(request.getRotationMin() != null) ? request.getRotationMin() : rotation;
float rotationMax =
(request.getRotationMax() != null) ? request.getRotationMax() : rotation;
float margin = (request.getMargin() != null) ? request.getMargin() : 10f;
// Parse bounds if provided
Float boundsX = null, boundsY = null, boundsWidth = null, boundsHeight = null;
if (request.getBounds() != null && !request.getBounds().isEmpty()) {
String[] boundsParts = request.getBounds().split(",");
if (boundsParts.length == 4) {
boundsX = Float.parseFloat(boundsParts[0].trim());
boundsY = Float.parseFloat(boundsParts[1].trim());
boundsWidth = Float.parseFloat(boundsParts[2].trim());
boundsHeight = Float.parseFloat(boundsParts[3].trim());
}
}
// Load the watermark image
BufferedImage image = ImageIO.read(watermarkImage.getInputStream());
// Compute width based on original aspect ratio
// Compute width based on an original aspect ratio
float aspectRatio = (float) image.getWidth() / (float) image.getHeight();
// Desired physical height (in PDF points)
float desiredPhysicalHeight = fontSize;
// Desired physical height (in PDF points) with scale applied
float desiredPhysicalHeight = fontSize * imageScale;
// Desired physical width based on the aspect ratio
float desiredPhysicalWidth = desiredPhysicalHeight * aspectRatio;
@ -281,35 +664,108 @@ public class WatermarkController {
// Convert the BufferedImage to PDImageXObject
PDImageXObject xobject = LosslessFactory.createFromImage(document, image);
// Calculate the number of rows and columns for watermarks
// Get page dimensions
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
int watermarkRows =
(int) ((pageHeight + heightSpacer) / (desiredPhysicalHeight + heightSpacer));
int watermarkCols =
(int) ((pageWidth + widthSpacer) / (desiredPhysicalWidth + widthSpacer));
for (int i = 0; i < watermarkRows; i++) {
for (int j = 0; j < watermarkCols; j++) {
float x = j * (desiredPhysicalWidth + widthSpacer);
float y = i * (desiredPhysicalHeight + heightSpacer);
// Save the graphics state
contentStream.saveGraphicsState();
// Create rotation matrix and rotate
contentStream.transform(
Matrix.getTranslateInstance(
x + desiredPhysicalWidth / 2, y + desiredPhysicalHeight / 2));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.transform(
Matrix.getTranslateInstance(
-desiredPhysicalWidth / 2, -desiredPhysicalHeight / 2));
// Draw the image and restore the graphics state
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.restoreGraphicsState();
// Determine positions based on a randomPosition flag
java.util.List<float[]> positions;
if (randomPosition) {
// Generate random positions
positions = new java.util.ArrayList<>();
for (int i = 0; i < count; i++) {
float[] pos =
randomizer.generateRandomPosition(
pageWidth,
pageHeight,
desiredPhysicalWidth,
desiredPhysicalHeight,
margin,
boundsX,
boundsY,
boundsWidth,
boundsHeight);
positions.add(pos);
}
} else {
// Generate grid positions (backward compatible)
positions =
randomizer.generateGridPositions(
pageWidth,
pageHeight,
desiredPhysicalWidth + widthSpacer,
desiredPhysicalHeight + heightSpacer,
widthSpacer,
heightSpacer,
count);
}
// Render each watermark instance
for (float[] pos : positions) {
float x = pos[0];
float y = pos[1];
// Determine rotation for this watermark
float wmRotation = randomizer.generateRandomRotation(rotationMin, rotationMax);
// Determine if this watermark should be mirrored
boolean shouldMirror = randomMirroring && randomizer.shouldMirror(mirroringProbability);
// Save the graphics state
contentStream.saveGraphicsState();
// Translate to center of image position
contentStream.transform(
Matrix.getTranslateInstance(
x + desiredPhysicalWidth / 2, y + desiredPhysicalHeight / 2));
// Apply rotation
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(wmRotation), 0, 0));
// Apply mirroring if needed (horizontal flip)
if (shouldMirror) {
contentStream.transform(Matrix.getScaleInstance(-1, 1));
}
// Translate back to draw from corner
contentStream.transform(
Matrix.getTranslateInstance(
-desiredPhysicalWidth / 2, -desiredPhysicalHeight / 2));
// Draw the image and restore the graphics state
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.restoreGraphicsState();
}
}
/**
* Applies shading to a color by adjusting its intensity.
*
* @param color Original color
* @param shading Shading style: "none", "light", or "dark"
* @return Color with shading applied
*/
private Color applyShadingToColor(Color color, String shading) {
if (shading == null || "none".equalsIgnoreCase(shading)) {
return color;
}
int r = color.getRed();
int g = color.getGreen();
int b = color.getBlue();
if ("light".equalsIgnoreCase(shading)) {
// Lighten the color by moving towards white
r = r + (255 - r) / 2;
g = g + (255 - g) / 2;
b = b + (255 - b) / 2;
} else if ("dark".equalsIgnoreCase(shading)) {
// Darken the color by moving towards black
r = r / 2;
g = g / 2;
b = b / 2;
}
return new Color(r, g, b);
}
}

View File

@ -4,6 +4,12 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -54,4 +60,151 @@ public class AddWatermarkRequest extends PDFFile {
defaultValue = "false",
requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean convertPDFToImage;
// New fields for enhanced watermarking (Phase 1)
@Schema(description = "Number of watermark instances per page or document", defaultValue = "1")
@Min(value = 1, message = "Count must be at least 1")
@Max(value = 1000, message = "Count must not exceed 1000")
private Integer count;
@Schema(description = "Enable random positioning of watermarks", defaultValue = "false")
private Boolean randomPosition;
@Schema(
description = "Minimum rotation angle in degrees (used when rotation range is enabled)",
defaultValue = "0")
@DecimalMin(value = "-360.0", message = "Rotation minimum must be >= -360")
@DecimalMax(value = "360.0", message = "Rotation minimum must be <= 360")
private Float rotationMin;
@Schema(
description = "Maximum rotation angle in degrees (used when rotation range is enabled)",
defaultValue = "0")
@DecimalMin(value = "-360.0", message = "Rotation maximum must be >= -360")
@DecimalMax(value = "360.0", message = "Rotation maximum must be <= 360")
private Float rotationMax;
@Schema(description = "Enable random mirroring of watermarks", defaultValue = "false")
private Boolean randomMirroring;
@Schema(
description = "Probability of mirroring when randomMirroring is enabled (0.0 - 1.0)",
defaultValue = "0.5")
@DecimalMin(value = "0.0", message = "Mirroring probability must be >= 0.0")
@DecimalMax(value = "1.0", message = "Mirroring probability must be <= 1.0")
private Float mirroringProbability;
@Schema(description = "Specific font name to use for text watermarks")
private String fontName;
@Schema(
description = "Enable random font selection for text watermarks",
defaultValue = "false")
private Boolean randomFont;
@Schema(
description = "Minimum font size (used when font size range is enabled)",
defaultValue = "10")
@DecimalMin(value = "1.0", message = "Font size minimum must be >= 1.0")
@DecimalMax(value = "500.0", message = "Font size minimum must be <= 500.0")
private Float fontSizeMin;
@Schema(
description = "Maximum font size (used when font size range is enabled)",
defaultValue = "100")
@DecimalMin(value = "1.0", message = "Font size maximum must be >= 1.0")
@DecimalMax(value = "500.0", message = "Font size maximum must be <= 500.0")
private Float fontSizeMax;
@Schema(description = "Enable random color selection for watermarks", defaultValue = "false")
private Boolean randomColor;
@Schema(
description = "Enable per-letter font variation in text watermarks",
defaultValue = "false")
private Boolean perLetterFont;
@Schema(
description = "Enable per-letter color variation in text watermarks",
defaultValue = "false")
private Boolean perLetterColor;
@Schema(
description = "Enable per-letter size variation in text watermarks",
defaultValue = "false")
private Boolean perLetterSize;
@Schema(
description = "Enable per-letter orientation variation in text watermarks",
defaultValue = "false")
private Boolean perLetterOrientation;
@Schema(
description = "Number of fonts to randomly select from for per-letter font variation",
defaultValue = "2")
@Min(value = 1, message = "Font count must be at least 1")
@Max(value = 20, message = "Font count must not exceed 20")
private Integer perLetterFontCount;
@Schema(description = "Minimum font size for per-letter size variation", defaultValue = "10")
@DecimalMin(value = "1.0", message = "Per-letter size minimum must be >= 1.0")
@DecimalMax(value = "500.0", message = "Per-letter size minimum must be <= 500.0")
private Float perLetterSizeMin;
@Schema(description = "Maximum font size for per-letter size variation", defaultValue = "100")
@DecimalMin(value = "1.0", message = "Per-letter size maximum must be >= 1.0")
@DecimalMax(value = "500.0", message = "Per-letter size maximum must be <= 500.0")
private Float perLetterSizeMax;
@Schema(
description = "Number of colors to randomly select from for per-letter color variation",
defaultValue = "4")
@Min(value = 1, message = "Color count must be at least 1")
@Max(value = 20, message = "Color count must not exceed 20")
private Integer perLetterColorCount;
@Schema(
description = "Minimum rotation angle in degrees for per-letter orientation variation",
defaultValue = "0")
@DecimalMin(value = "-360.0", message = "Per-letter orientation minimum must be >= -360")
@DecimalMax(value = "360.0", message = "Per-letter orientation minimum must be <= 360")
private Float perLetterOrientationMin;
@Schema(
description = "Maximum rotation angle in degrees for per-letter orientation variation",
defaultValue = "360")
@DecimalMin(value = "-360.0", message = "Per-letter orientation maximum must be >= -360")
@DecimalMax(value = "360.0", message = "Per-letter orientation maximum must be <= 360")
private Float perLetterOrientationMax;
@Schema(description = "Shading style for text watermarks (e.g., 'none', 'light', 'dark')")
private String shading;
@Schema(description = "Enable random shading selection for watermarks", defaultValue = "false")
private Boolean shadingRandom;
@Schema(description = "Random seed for deterministic randomness (optional, for testing)")
private Long seed;
@Schema(description = "Minimum margin from page edges in points", defaultValue = "10")
@DecimalMin(value = "0.0", message = "Margin must be >= 0.0")
@DecimalMax(value = "500.0", message = "Margin must be <= 500.0")
private Float margin;
@Schema(
description =
"Bounding box constraint for watermark placement (format: x,y,width,height)")
@Pattern(
regexp =
"^(\\d+(\\.\\d+)?),\\s*(\\d+(\\.\\d+)?),\\s*(\\d+(\\.\\d+)?),\\s*(\\d+(\\.\\d+)?)$",
message = "Bounds must be in format: x,y,width,height")
private String bounds;
@Schema(
description = "Scale factor for image watermarks (1.0 = original size)",
defaultValue = "1.0")
@DecimalMin(value = "0.1", message = "Image scale must be >= 0.1")
@DecimalMax(value = "10.0", message = "Image scale must be <= 10.0")
private Float imageScale;
}

View File

@ -30,6 +30,7 @@
<option value="image" th:text="#{watermark.type.2}"></option>
</select>
</div>
<div id="alphabetGroup" class="mb-3">
<label for="fontSize" th:text="#{alphabet} + ':'"></label>
<select class="form-control" name="alphabet" id="alphabet-select">
@ -51,10 +52,11 @@
<input type="file" id="watermarkImage" name="watermarkImage" class="form-control-file" accept="image/*">
</div>
<div class="mb-3">
<label for="fontSize" th:text="#{watermark.selectText.3}"></label>
<input type="text" id="fontSize" name="fontSize" class="form-control" value="30">
<div id="fontSizeFixedGroup" class="mb-3">
<label for="fontSize" th:text="#{watermark.selectText.3}">Font Size</label>
<input type="number" id="fontSize" name="fontSize" class="form-control" value="30" min="1" max="500" step="0.1">
</div>
<div class="mb-3">
<label for="opacity" th:text="#{watermark.selectText.7}"></label>
<input type="text" id="opacity" name="opacityText" class="form-control" value="50" onblur="updateOpacityValue()">
@ -93,38 +95,242 @@
appendPercentageSymbol();
</script>
<div class="mb-3">
<label for="rotation" th:text="#{watermark.selectText.4}"></label>
<input type="text" id="rotation" name="rotation" class="form-control" value="45">
</div>
<div class="mb-3">
<label for="widthSpacer" th:text="#{watermark.selectText.5}"></label>
<input type="text" id="widthSpacer" name="widthSpacer" class="form-control" value="50">
</div>
<div class="mb-3">
<label for="heightSpacer" th:text="#{watermark.selectText.6}"></label>
<input type="text" id="heightSpacer" name="heightSpacer" class="form-control" value="50">
</div>
<div class="mb-3">
<label for="customColor" class="form-label" th:text="#{watermark.customColor}">Custom
Color</label>
<div class="form-control form-control-color" style="background-color: #d3d3d3;">
<input type="color" id="customColor" name="customColor" value="#d3d3d3">
</div>
<script>
let colorInput = document.getElementById("customColor");
if (colorInput) {
let colorInputContainer = colorInput.parentElement;
if (colorInputContainer) {
colorInput.onchange = function() {
colorInputContainer.style.backgroundColor = colorInput.value;
}
colorInputContainer.style.backgroundColor = colorInput.value;
}
}
<div id="rotationFixedGroup" class="mb-3">
<label for="rotation" th:text="#{watermark.selectText.4}">Rotation Angle (degrees)</label>
<input type="number" id="rotation" name="rotation" class="form-control" value="45" min="-360" max="360" step="0.1">
</div>
</script>
<div class="mb-3">
<label for="widthSpacer" th:text="#{watermark.selectText.5}"></label>
<input type="text" id="widthSpacer" name="widthSpacer" class="form-control" value="50">
</div>
<div class="mb-3">
<label for="heightSpacer" th:text="#{watermark.selectText.6}"></label>
<input type="text" id="heightSpacer" name="heightSpacer" class="form-control" value="50">
</div>
<div id="customColorGroup" class="mb-3">
<label for="customColor" class="form-label" th:text="#{watermark.customColor}">Custom Color</label>
<div class="form-control form-control-color" style="background-color: #d3d3d3;">
<input type="color" id="customColor" name="customColor" value="#d3d3d3">
</div>
<script>
let colorInput = document.getElementById("customColor");
if (colorInput) {
let colorInputContainer = colorInput.parentElement;
if (colorInputContainer) {
colorInput.onchange = function() {
colorInputContainer.style.backgroundColor = colorInput.value;
}
colorInputContainer.style.backgroundColor = colorInput.value;
}
}
</script>
</div>
<!-- Advanced Options -->
<div class="mb-3">
<button type="button" class="btn btn-link" data-bs-toggle="collapse" data-bs-target="#advancedOptions">
Advanced Options
</button>
<div id="advancedOptions" class="collapse">
<!-- Watermark Count -->
<div class="mb-3">
<label for="count">Number of Watermarks</label>
<input type="number" id="count" name="count" class="form-control" value="1" min="1" max="1000">
<small class="form-text text-muted">Number of watermark instances per page (1-1000)</small>
</div>
<!-- Positioning Mode -->
<div class="mb-3">
<div class="form-check">
<input type="checkbox" id="randomPosition" name="randomPosition" class="form-check-input">
<label for="randomPosition" class="form-check-label">Random Positioning</label>
</div>
<small class="form-text text-muted">Enable random placement instead of grid layout</small>
</div>
<div id="marginGroup" class="mb-3">
<label for="margin">Margin from Edges (points)</label>
<input type="number" id="margin" name="margin" class="form-control" value="10" min="0" max="500" step="0.1">
<small class="form-text text-muted">Minimum distance from page edges (0-500)</small>
</div>
<div id="boundsGroup" class="mb-3">
<label for="bounds">Bounding Box (optional)</label>
<input type="text" id="bounds" name="bounds" class="form-control" placeholder="x,y,width,height">
<small class="form-text text-muted">Constrain placement to specific area (format: x,y,width,height)</small>
</div>
<!-- Rotation Controls -->
<div class="mb-3">
<label>Rotation Mode</label>
<select class="form-control" id="rotationMode" onchange="toggleRotationMode()">
<option value="fixed">Fixed Angle</option>
<option value="range">Random Range</option>
</select>
</div>
<div id="rotationRangeGroup" class="mb-3" style="display: none;">
<label for="rotationMin">Rotation Min (degrees)</label>
<input type="number" id="rotationMin" name="rotationMin" class="form-control" value="0" min="-360" max="360" step="0.1">
<label for="rotationMax">Rotation Max (degrees)</label>
<input type="number" id="rotationMax" name="rotationMax" class="form-control" value="45" min="-360" max="360" step="0.1">
<small class="form-text text-muted">Random angle between min and max</small>
<div id="rotationRangeError" class="invalid-feedback" style="display: none;">
Minimum must be less than or equal to maximum
</div>
</div>
<!-- Mirroring Controls -->
<div class="mb-3">
<div class="form-check">
<input type="checkbox" id="randomMirroring" name="randomMirroring" class="form-check-input" onchange="toggleMirroringProbability()">
<label for="randomMirroring" class="form-check-label">Random Mirroring</label>
<small class="form-text text-muted">For images only</small>
</div>
</div>
<div id="mirroringProbabilityGroup" class="mb-3" style="display: none;">
<label for="mirroringProbability">Mirroring Probability</label>
<input type="number" id="mirroringProbability" name="mirroringProbability" class="form-control" value="0.5" min="0" max="1" step="0.1">
<small class="form-text text-muted">Probability of mirroring (0.0-1.0)</small>
</div>
<!-- Font Controls (for text watermarks) -->
<div id="fontGroup" class="mb-3">
<div class="form-check">
<input type="checkbox" id="randomFont" name="randomFont" class="form-check-input" onchange="toggleFontMode()">
<label for="randomFont" class="form-check-label">Random Font</label>
</div>
<small class="form-text text-muted">Enable random font selection for text watermarks</small>
</div>
<div id="fontNameGroup" class="mb-3">
<label for="fontName">Font Name</label>
<input type="text" id="fontName" name="fontName" class="form-control" placeholder="e.g., Helvetica, Times-Roman">
<small class="form-text text-muted">Leave empty for default font</small>
</div>
<div class="mb-3">
<label>Font Size Mode</label>
<select class="form-control" id="fontSizeMode" onchange="toggleFontSizeMode()">
<option value="fixed">Fixed Size</option>
<option value="range">Random Range</option>
</select>
</div>
<div id="fontSizeRangeGroup" class="mb-3" style="display: none;">
<label for="fontSizeMin">Font Size Min</label>
<input type="number" id="fontSizeMin" name="fontSizeMin" class="form-control" value="10" min="1" max="500" step="0.1">
<label for="fontSizeMax">Font Size Max</label>
<input type="number" id="fontSizeMax" name="fontSizeMax" class="form-control" value="100" min="1" max="500" step="0.1">
<small class="form-text text-muted">Random size between min and max</small>
<div id="fontSizeRangeError" class="invalid-feedback" style="display: none;">
Minimum must be less than or equal to maximum
</div>
</div>
<!-- Color Controls -->
<div class="mb-3">
<div class="form-check">
<input type="checkbox" id="randomColor" name="randomColor" class="form-check-input" onchange="toggleColorMode()">
<label for="randomColor" class="form-check-label">Random Color</label>
</div>
</div>
<!-- Per-Letter Variation Controls (for text watermarks) -->
<div id="perLetterGroup" class="mb-3">
<label>Per-Letter Variations</label>
<!-- Per-Letter Font -->
<div class="form-check">
<input type="checkbox" id="perLetterFont" name="perLetterFont" class="form-check-input" onchange="togglePerLetterFontConfig()">
<label for="perLetterFont" class="form-check-label">Vary Font per Letter</label>
</div>
<div id="perLetterFontConfig" class="mb-2 ms-4" style="display: none;">
<label for="perLetterFontCount">Number of Fonts</label>
<input type="number" id="perLetterFontCount" name="perLetterFontCount" class="form-control" value="2" min="1" max="20">
<small class="form-text text-muted">Number of fonts to randomly select from (1-20, default: 2)</small>
</div>
<!-- Per-Letter Color -->
<div class="form-check">
<input type="checkbox" id="perLetterColor" name="perLetterColor" class="form-check-input" onchange="togglePerLetterColorConfig()">
<label for="perLetterColor" class="form-check-label">Vary Color per Letter</label>
</div>
<div id="perLetterColorConfig" class="mb-2 ms-4" style="display: none;">
<label for="perLetterColorCount">Number of Colors</label>
<input type="number" id="perLetterColorCount" name="perLetterColorCount" class="form-control" value="4" min="1" max="12">
<small class="form-text text-muted">Number of colors to randomly select from (1-12, default: 4)</small>
</div>
<!-- Per-Letter Size -->
<div class="form-check">
<input type="checkbox" id="perLetterSize" name="perLetterSize" class="form-check-input" onchange="togglePerLetterSizeConfig()">
<label for="perLetterSize" class="form-check-label">Vary Size per Letter</label>
</div>
<div id="perLetterSizeConfig" class="mb-2 ms-4" style="display: none;">
<label for="perLetterSizeMin">Size Min</label>
<input type="number" id="perLetterSizeMin" name="perLetterSizeMin" class="form-control" value="10" min="1" max="500" step="0.1">
<label for="perLetterSizeMax">Size Max</label>
<input type="number" id="perLetterSizeMax" name="perLetterSizeMax" class="form-control" value="100" min="1" max="500" step="0.1">
<small class="form-text text-muted">Font size range (1-500, default: 10-100)</small>
<div id="perLetterSizeError" class="invalid-feedback" style="display: none;">
Minimum must be less than or equal to maximum
</div>
</div>
<!-- Per-Letter Orientation -->
<div class="form-check">
<input type="checkbox" id="perLetterOrientation" name="perLetterOrientation" class="form-check-input" onchange="togglePerLetterOrientationConfig()">
<label for="perLetterOrientation" class="form-check-label">Vary Orientation per Letter</label>
</div>
<div id="perLetterOrientationConfig" class="mb-2 ms-4" style="display: none;">
<label for="perLetterOrientationMin">Angle Min (degrees)</label>
<input type="number" id="perLetterOrientationMin" name="perLetterOrientationMin" class="form-control" value="0" min="-360" max="360" step="1">
<label for="perLetterOrientationMax">Angle Max (degrees)</label>
<input type="number" id="perLetterOrientationMax" name="perLetterOrientationMax" class="form-control" value="360" min="-360" max="360" step="1">
<small class="form-text text-muted">Rotation angle range (-360 to 360, default: 0-360)</small>
<div id="perLetterOrientationError" class="invalid-feedback" style="display: none;">
Minimum must be less than or equal to maximum
</div>
</div>
<small class="form-text text-muted">Apply variations at the letter level for dynamic appearance</small>
</div>
<!-- Shading Controls (for text watermarks) -->
<div id="shadingGroup" class="mb-3">
<div class="form-check">
<input type="checkbox" id="shadingRandom" name="shadingRandom" class="form-check-input" onchange="toggleShadingMode()">
<label for="shadingRandom" class="form-check-label">Random Shading</label>
</div>
</div>
<div id="shadingFixedGroup" class="mb-3">
<label for="shading">Shading Style</label>
<select class="form-control" id="shading" name="shading">
<option value="none">None</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<!-- Image Scale Control (for image watermarks) -->
<div id="imageScaleGroup" class="mb-3" style="display: none;">
<label for="imageScale">Image Scale Factor</label>
<input type="number" id="imageScale" name="imageScale" class="form-control" value="1.0" min="0.1" max="10" step="0.1">
<small class="form-text text-muted">Scale factor (1.0 = original size, 0.1-10.0)</small>
</div>
<div class="mb-3">
<label for="seed">Random Seed (optional)</label>
<input type="number" id="seed" name="seed" class="form-control" placeholder="Leave empty for random">
<small class="form-text text-muted">For deterministic randomness (testing)</small>
</div>
</div>
</div>
<div class="form-check mb-3">
@ -141,7 +347,7 @@
const watermarkType = document.getElementById('watermarkType').value;
const watermarkTextGroup = document.getElementById('watermarkTextGroup');
const watermarkImageGroup = document.getElementById('watermarkImageGroup');
const alphabetGroup = document.getElementById('alphabetGroup'); // This is the new addition
const alphabetGroup = document.getElementById('alphabetGroup');
const watermarkText = document.getElementById('watermarkText');
const watermarkImage = document.getElementById('watermarkImage');
@ -154,6 +360,17 @@
watermarkImageGroup.style.display = 'none';
watermarkImage.required = false;
alphabetGroup.style.display = 'block';
// Show text-specific controls
const textSpecific = ['fontGroup', 'fontNameGroup', 'perLetterGroup', 'shadingGroup', 'shadingFixedGroup'];
textSpecific.forEach(id => {
const elem = document.getElementById(id);
if (elem) elem.style.display = 'block';
});
// Hide image-specific controls
const imageScaleGroup = document.getElementById('imageScaleGroup');
if (imageScaleGroup) imageScaleGroup.style.display = 'none';
} else if (watermarkType === 'image') {
if (watermarkImage.hasAttribute('required')) {
watermarkImage.removeAttribute('required');
@ -163,8 +380,266 @@
watermarkImageGroup.style.display = 'block';
watermarkImage.required = true;
alphabetGroup.style.display = 'none';
// Hide text-specific controls
const textSpecific = ['fontGroup', 'fontNameGroup', 'perLetterGroup', 'shadingGroup', 'shadingFixedGroup'];
textSpecific.forEach(id => {
const elem = document.getElementById(id);
if (elem) elem.style.display = 'none';
});
// Show image-specific controls
const imageScaleGroup = document.getElementById('imageScaleGroup');
if (imageScaleGroup) imageScaleGroup.style.display = 'block';
}
}
function toggleRotationMode() {
const mode = document.getElementById('rotationMode').value;
const rotationFixed = document.getElementById('rotation');
const rotationMin = document.getElementById('rotationMin');
const rotationMax = document.getElementById('rotationMax');
if (mode === 'fixed') {
document.getElementById('rotationFixedGroup').style.display = 'block';
document.getElementById('rotationRangeGroup').style.display = 'none';
if (rotationFixed) rotationFixed.disabled = false;
if (rotationMin) rotationMin.disabled = true;
if (rotationMax) rotationMax.disabled = true;
} else {
document.getElementById('rotationFixedGroup').style.display = 'none';
document.getElementById('rotationRangeGroup').style.display = 'block';
if (rotationFixed) rotationFixed.disabled = true;
if (rotationMin) rotationMin.disabled = false;
if (rotationMax) rotationMax.disabled = false;
}
}
function toggleMirroringProbability() {
const enabled = document.getElementById('randomMirroring').checked;
document.getElementById('mirroringProbabilityGroup').style.display =
enabled ? 'block' : 'none';
}
function toggleFontMode() {
const randomFont = document.getElementById('randomFont').checked;
document.getElementById('fontNameGroup').style.display =
randomFont ? 'none' : 'block';
}
function toggleFontSizeMode() {
const mode = document.getElementById('fontSizeMode').value;
const fontSizeFixed = document.getElementById('fontSize');
const fontSizeMin = document.getElementById('fontSizeMin');
const fontSizeMax = document.getElementById('fontSizeMax');
if (mode === 'fixed') {
document.getElementById('fontSizeFixedGroup').style.display = 'block';
document.getElementById('fontSizeRangeGroup').style.display = 'none';
if (fontSizeFixed) fontSizeFixed.disabled = false;
if (fontSizeMin) fontSizeMin.disabled = true;
if (fontSizeMax) fontSizeMax.disabled = true;
} else {
document.getElementById('fontSizeFixedGroup').style.display = 'none';
document.getElementById('fontSizeRangeGroup').style.display = 'block';
if (fontSizeFixed) fontSizeFixed.disabled = true;
if (fontSizeMin) fontSizeMin.disabled = false;
if (fontSizeMax) fontSizeMax.disabled = false;
}
}
function toggleColorMode() {
const random = document.getElementById('randomColor').checked;
document.getElementById('customColorGroup').style.display =
random ? 'none' : 'block';
}
function toggleShadingMode() {
const random = document.getElementById('shadingRandom').checked;
document.getElementById('shadingFixedGroup').style.display =
random ? 'none' : 'block';
}
function togglePerLetterFontConfig() {
const checkbox = document.getElementById('perLetterFont');
const config = document.getElementById('perLetterFontConfig');
config.style.display = checkbox.checked ? 'block' : 'none';
}
function togglePerLetterColorConfig() {
const checkbox = document.getElementById('perLetterColor');
const config = document.getElementById('perLetterColorConfig');
config.style.display = checkbox.checked ? 'block' : 'none';
}
function togglePerLetterSizeConfig() {
const checkbox = document.getElementById('perLetterSize');
const config = document.getElementById('perLetterSizeConfig');
config.style.display = checkbox.checked ? 'block' : 'none';
}
function togglePerLetterOrientationConfig() {
const checkbox = document.getElementById('perLetterOrientation');
const config = document.getElementById('perLetterOrientationConfig');
config.style.display = checkbox.checked ? 'block' : 'none';
}
// Add range validation
function addRangeValidation(minId, maxId, errorMsgId) {
const minInput = document.getElementById(minId);
const maxInput = document.getElementById(maxId);
const errorMsg = document.getElementById(errorMsgId);
if (!minInput || !maxInput) return;
function validate() {
const min = parseFloat(minInput.value);
const max = parseFloat(maxInput.value);
if (min > max) {
minInput.classList.add('is-invalid');
maxInput.classList.add('is-invalid');
if (errorMsg) errorMsg.style.display = 'block';
} else {
minInput.classList.remove('is-invalid');
maxInput.classList.remove('is-invalid');
if (errorMsg) errorMsg.style.display = 'none';
}
}
minInput.addEventListener('input', validate);
maxInput.addEventListener('input', validate);
}
// Apply range validations on page load
document.addEventListener('DOMContentLoaded', function() {
addRangeValidation('rotationMin', 'rotationMax', 'rotationRangeError');
addRangeValidation('fontSizeMin', 'fontSizeMax', 'fontSizeRangeError');
addRangeValidation('perLetterSizeMin', 'perLetterSizeMax', 'perLetterSizeError');
addRangeValidation('perLetterOrientationMin', 'perLetterOrientationMax', 'perLetterOrientationError');
// Initialize toggle states to disable hidden inputs
toggleRotationMode();
toggleFontSizeMode();
});
// Form submission validation
document.querySelector('form').addEventListener('submit', function(e) {
const errors = [];
// Validate count
const count = parseInt(document.getElementById('count').value);
if (count < 1 || count > 1000) {
errors.push('Count must be between 1 and 1000');
}
// Validate rotation range
const rotationMode = document.getElementById('rotationMode').value;
if (rotationMode === 'range') {
const rotMin = parseFloat(document.getElementById('rotationMin').value);
const rotMax = parseFloat(document.getElementById('rotationMax').value);
if (rotMin > rotMax) {
errors.push('Rotation minimum must be less than or equal to maximum');
}
}
// Validate font size range
const fontSizeMode = document.getElementById('fontSizeMode').value;
if (fontSizeMode === 'range') {
const sizeMin = parseFloat(document.getElementById('fontSizeMin').value);
const sizeMax = parseFloat(document.getElementById('fontSizeMax').value);
if (sizeMin > sizeMax) {
errors.push('Font size minimum must be less than or equal to maximum');
}
if (sizeMin < 1 || sizeMax > 500) {
errors.push('Font size must be between 1 and 500');
}
}
// Validate margin
const margin = parseFloat(document.getElementById('margin').value);
if (margin < 0 || margin > 500) {
errors.push('Margin must be between 0 and 500');
}
// Validate bounds format (if provided)
const bounds = document.getElementById('bounds').value.trim();
if (bounds) {
const boundsPattern = /^(\d+(\.\d+)?),\s*(\d+(\.\d+)?),\s*(\d+(\.\d+)?),\s*(\d+(\.\d+)?)$/;
if (!boundsPattern.test(bounds)) {
errors.push('Bounds must be in format: x,y,width,height');
}
}
// Validate mirroring probability
if (document.getElementById('randomMirroring').checked) {
const prob = parseFloat(document.getElementById('mirroringProbability').value);
if (prob < 0 || prob > 1) {
errors.push('Mirroring probability must be between 0.0 and 1.0');
}
}
// Validate image scale
const watermarkType = document.getElementById('watermarkType').value;
if (watermarkType === 'image') {
const scale = parseFloat(document.getElementById('imageScale').value);
if (scale < 0.1 || scale > 10) {
errors.push('Image scale must be between 0.1 and 10.0');
}
}
// Validate opacity
const opacity = parseFloat(document.getElementById('opacityReal').value);
if (opacity < 0 || opacity > 1) {
errors.push('Opacity must be between 0.0 and 1.0');
}
// Validate per-letter configurations
if (document.getElementById('perLetterFont').checked) {
const fontCount = parseInt(document.getElementById('perLetterFontCount').value);
if (fontCount < 1 || fontCount > 20) {
errors.push('Per-letter font count must be between 1 and 20');
}
}
if (document.getElementById('perLetterColor').checked) {
const colorCount = parseInt(document.getElementById('perLetterColorCount').value);
if (colorCount < 1 || colorCount > 12) {
errors.push('Per-letter color count must be between 1 and 12');
}
}
if (document.getElementById('perLetterSize').checked) {
const sizeMin = parseFloat(document.getElementById('perLetterSizeMin').value);
const sizeMax = parseFloat(document.getElementById('perLetterSizeMax').value);
if (sizeMin > sizeMax) {
errors.push('Per-letter size minimum must be less than or equal to maximum');
}
if (sizeMin < 1 || sizeMax > 500) {
errors.push('Per-letter size must be between 1 and 500');
}
}
if (document.getElementById('perLetterOrientation').checked) {
const orientMin = parseFloat(document.getElementById('perLetterOrientationMin').value);
const orientMax = parseFloat(document.getElementById('perLetterOrientationMax').value);
if (orientMin > orientMax) {
errors.push('Per-letter orientation minimum must be less than or equal to maximum');
}
if (orientMin < -360 || orientMax > 360) {
errors.push('Per-letter orientation must be between -360 and 360');
}
}
// Display errors
if (errors.length > 0) {
e.preventDefault();
alert('Please fix the following errors:\n\n' + errors.join('\n'));
return false;
}
return true;
});
</script>
</div>
</div>

View File

@ -0,0 +1,730 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
@DisplayName("Watermark Controller Integration Tests")
class WatermarkControllerIntegrationTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private WatermarkController watermarkController;
private MockMultipartFile testPdfFile;
private MockMultipartFile testImageFile;
@BeforeEach
void setUp() throws IOException {
// Create a simple test PDF
PDDocument document = new PDDocument();
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
File tempPdf = File.createTempFile("test", ".pdf");
document.save(tempPdf);
document.close();
byte[] pdfBytes = Files.readAllBytes(tempPdf.toPath());
testPdfFile = new MockMultipartFile("fileInput", "test.pdf", "application/pdf", pdfBytes);
tempPdf.delete();
// Create a simple test image (1x1 pixel PNG)
byte[] imageBytes =
new byte[] {
(byte) 0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
(byte) 0xC4,
(byte) 0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
(byte) 0x9C,
0x63,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
(byte) 0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
(byte) 0xAE,
0x42,
0x60,
(byte) 0x82
};
testImageFile =
new MockMultipartFile("watermarkImage", "test.png", "image/png", imageBytes);
// Configure mock to return a real PDDocument when load is called
when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class)))
.thenAnswer(
invocation -> {
org.springframework.web.multipart.MultipartFile file =
invocation.getArgument(0);
return Loader.loadPDF(file.getBytes());
});
}
@Nested
@DisplayName("Text Watermark Integration Tests")
class TextWatermarkIntegrationTests {
@Test
@DisplayName("Should apply text watermark with fixed positioning")
void testTextWatermarkFixedPositioning() throws Exception {
AddWatermarkRequest request = new AddWatermarkRequest();
request.setAlphabet("roman");
request.setFileInput(testPdfFile);
request.setWatermarkType("text");
request.setWatermarkText("Test Watermark");
request.setOpacity(0.5f);
request.setFontSize(30f);
request.setRotation(45f);
request.setWidthSpacer(100);
request.setHeightSpacer(100);
request.setCustomColor("#FF0000");
request.setConvertPDFToImage(false);
request.setRandomPosition(false);
ResponseEntity<byte[]> response = watermarkController.addWatermark(request);
assertNotNull(response, "Response should not be null");
assertEquals(200, response.getStatusCode().value(), "Should return 200 OK");
assertNotNull(response.getBody(), "Response body should not be null");
assertTrue(response.getBody().length > 0, "Response should contain PDF data");
// Verify the output is a valid PDF
try (PDDocument resultDoc = Loader.loadPDF(response.getBody())) {
assertEquals(1, resultDoc.getNumberOfPages(), "Should have 1 page");
}
}
@Test
@DisplayName("Should apply text watermark with random positioning")
void testTextWatermarkRandomPositioning() throws Exception {
AddWatermarkRequest request = new AddWatermarkRequest();
request.setAlphabet("roman");
request.setFileInput(testPdfFile);
request.setWatermarkType("text");
request.setWatermarkText("Random");
request.setOpacity(0.7f);
request.setFontSize(20f);
request.setCustomColor("#0000FF");
request.setConvertPDFToImage(false);
request.setRandomPosition(true);
request.setCount(5);
request.setMargin(10f);
request.setSeed(12345L); // Use seed for deterministic testing
ResponseEntity<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> response = watermarkController.addWatermark(request);
assertNotNull(response, "Response should not be null");
assertEquals(200, response.getStatusCode().value(), "Should return 200 OK");
}
@Test
@DisplayName("Should apply text watermark with custom bounds")
void testTextWatermarkWithBounds() throws Exception {
AddWatermarkRequest request = new AddWatermarkRequest();
request.setAlphabet("roman");
request.setFileInput(testPdfFile);
request.setWatermarkType("text");
request.setWatermarkText("Bounded");
request.setOpacity(0.5f);
request.setFontSize(25f);
request.setCustomColor("#00FF00");
request.setConvertPDFToImage(false);
request.setRandomPosition(true);
request.setCount(3);
request.setBounds("100,100,300,200");
request.setSeed(99000L);
ResponseEntity<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> response = watermarkController.addWatermark(request);
assertNotNull(response, "Response should not be null");
assertEquals(200, response.getStatusCode().value(), "Should return 200 OK");
}
@Test
@DisplayName("Should apply multiple image watermarks with random positioning")
void testMultipleImageWatermarksRandom() throws Exception {
AddWatermarkRequest request = new AddWatermarkRequest();
request.setAlphabet("roman");
request.setFileInput(testPdfFile);
request.setWatermarkType("image");
request.setWatermarkImage(testImageFile);
request.setOpacity(0.5f);
request.setRandomPosition(true);
request.setCount(5);
request.setMargin(20f);
request.setSeed(45454L);
request.setConvertPDFToImage(false);
ResponseEntity<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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");
}
}
}

View File

@ -0,0 +1,711 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.Collections;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@DisplayName("Watermark Validation Tests")
@ExtendWith(MockitoExtension.class)
class WatermarkValidationTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private WatermarkController watermarkController;
private AddWatermarkRequest request;
private MockMultipartFile mockPdfFile;
@BeforeEach
void setUp() throws Exception {
request = new AddWatermarkRequest();
mockPdfFile =
new MockMultipartFile(
"fileInput", "test.pdf", "application/pdf", "test content".getBytes());
request.setFileInput(mockPdfFile);
request.setWatermarkType("text");
request.setWatermarkText("Test Watermark");
request.setOpacity(0.5f);
request.setConvertPDFToImage(false);
// Mock PDDocument with empty pages to avoid NullPointerException
// Use lenient() because some tests don't reach the document loading code
PDDocument mockDocument = mock(PDDocument.class);
lenient().when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(mockDocument);
// Mock getPages() to return an empty iterable
org.apache.pdfbox.pdmodel.PDPageTree mockPageTree =
mock(org.apache.pdfbox.pdmodel.PDPageTree.class);
lenient().when(mockDocument.getPages()).thenReturn(mockPageTree);
lenient().when(mockPageTree.iterator()).thenReturn(Collections.emptyIterator());
}
@Nested
@DisplayName("Opacity Validation Tests")
class OpacityValidationTests {
@Test
@DisplayName("Should reject opacity below 0.0")
void testOpacityBelowMinimum() {
request.setOpacity(-0.1f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Opacity must be between 0.0 and 1.0"),
"Error message should mention opacity bounds");
}
@Test
@DisplayName("Should reject opacity above 1.0")
void testOpacityAboveMaximum() {
request.setOpacity(1.1f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Opacity must be between 0.0 and 1.0"),
"Error message should mention opacity bounds");
}
}
@Nested
@DisplayName("Rotation Range Validation Tests")
class RotationRangeValidationTests {
@Test
@DisplayName("Should accept valid rotation range")
void testValidRotationRange() {
request.setRotationMin(-45f);
request.setRotationMax(45f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept equal rotation min and max")
void testEqualRotationMinMax() {
request.setRotationMin(30f);
request.setRotationMax(30f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject rotation min greater than max")
void testRotationMinGreaterThanMax() {
request.setRotationMin(45f);
request.setRotationMax(-45f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Rotation minimum")
&& exception.getMessage().contains("must be less than or equal to"),
"Error message should mention rotation range constraint");
}
@Test
@DisplayName("Should accept null rotation values")
void testNullRotationValues() {
request.setRotationMin(null);
request.setRotationMax(null);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept only rotation min set")
void testOnlyRotationMinSet() {
request.setRotationMin(-30f);
request.setRotationMax(null);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept only rotation max set")
void testOnlyRotationMaxSet() {
request.setRotationMin(null);
request.setRotationMax(30f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
}
@Nested
@DisplayName("Font Size Range Validation Tests")
class FontSizeRangeValidationTests {
@Test
@DisplayName("Should accept valid font size range")
void testValidFontSizeRange() {
request.setFontSizeMin(10f);
request.setFontSizeMax(50f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept equal font size min and max")
void testEqualFontSizeMinMax() {
request.setFontSizeMin(30f);
request.setFontSizeMax(30f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject font size min greater than max")
void testFontSizeMinGreaterThanMax() {
request.setFontSizeMin(50f);
request.setFontSizeMax(10f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Font size minimum")
&& exception.getMessage().contains("must be less than or equal to"),
"Error message should mention font size range constraint");
}
@Test
@DisplayName("Should accept null font size values")
void testNullFontSizeValues() {
request.setFontSizeMin(null);
request.setFontSizeMax(null);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
}
@Nested
@DisplayName("Color Format Validation Tests")
class ColorFormatValidationTests {
@Test
@DisplayName("Should accept valid 6-digit hex color")
void testValidHexColor6Digits() {
request.setCustomColor("#FF0000");
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept valid 8-digit hex color with alpha")
void testValidHexColor8Digits() {
request.setCustomColor("#FF0000AA");
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept lowercase hex color")
void testValidHexColorLowercase() {
request.setCustomColor("#ff0000");
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept mixed case hex color")
void testValidHexColorMixedCase() {
request.setCustomColor("#Ff00Aa");
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject hex color without hash")
void testInvalidHexColorNoHash() {
request.setCustomColor("FF0000");
request.setRandomColor(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Invalid color format"),
"Error message should mention invalid color format");
}
@Test
@DisplayName("Should reject hex color with wrong length")
void testInvalidHexColorWrongLength() {
request.setCustomColor("#FFF");
request.setRandomColor(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Invalid color format"),
"Error message should mention invalid color format");
}
@Test
@DisplayName("Should reject hex color with invalid characters")
void testInvalidHexColorInvalidChars() {
request.setCustomColor("#GGGGGG");
request.setRandomColor(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Invalid color format"),
"Error message should mention invalid color format");
}
@Test
@DisplayName("Should skip color validation when using random color")
void testSkipValidationWithRandomColor() {
request.setCustomColor("invalid");
request.setRandomColor(true);
// Should not throw exception because random color is enabled
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should skip color validation when custom color is null")
void testSkipValidationWithNullColor() {
request.setCustomColor(null);
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
}
@Nested
@DisplayName("Mirroring Probability Validation Tests")
class MirroringProbabilityValidationTests {
@Test
@DisplayName("Should accept valid mirroring probability values")
void testValidMirroringProbability() {
request.setMirroringProbability(0.0f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
request.setMirroringProbability(0.5f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
request.setMirroringProbability(1.0f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject mirroring probability below 0.0")
void testMirroringProbabilityBelowMinimum() {
request.setMirroringProbability(-0.1f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception
.getMessage()
.contains("Mirroring probability must be between 0.0 and 1.0"),
"Error message should mention mirroring probability bounds");
}
@Test
@DisplayName("Should reject mirroring probability above 1.0")
void testMirroringProbabilityAboveMaximum() {
request.setMirroringProbability(1.5f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception
.getMessage()
.contains("Mirroring probability must be between 0.0 and 1.0"),
"Error message should mention mirroring probability bounds");
}
@Test
@DisplayName("Should accept null mirroring probability")
void testNullMirroringProbability() {
request.setMirroringProbability(null);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
}
@Nested
@DisplayName("Watermark Type Validation Tests")
class WatermarkTypeValidationTests {
@Test
@DisplayName("Should accept 'text' watermark type")
void testTextWatermarkType() {
request.setWatermarkType("text");
request.setWatermarkText("Test");
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept 'image' watermark type")
void testImageWatermarkType() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.png", "image/png", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept case-insensitive watermark type")
void testCaseInsensitiveWatermarkType() {
request.setWatermarkType("TEXT");
request.setWatermarkText("Test");
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
request.setWatermarkType("Image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.png", "image/png", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject invalid watermark type")
void testInvalidWatermarkType() {
request.setWatermarkType("invalid");
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark type must be 'text' or 'image'"),
"Error message should mention valid watermark types");
}
@Test
@DisplayName("Should reject null watermark type")
void testNullWatermarkType() {
request.setWatermarkType(null);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark type must be 'text' or 'image'"),
"Error message should mention valid watermark types");
}
@Test
@DisplayName("Should reject text watermark without text")
void testTextWatermarkWithoutText() {
request.setWatermarkType("text");
request.setWatermarkText(null);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark text is required"),
"Error message should mention missing watermark text");
}
@Test
@DisplayName("Should reject text watermark with empty text")
void testTextWatermarkWithEmptyText() {
request.setWatermarkType("text");
request.setWatermarkText(" ");
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark text is required"),
"Error message should mention missing watermark text");
}
@Test
@DisplayName("Should reject image watermark without image")
void testImageWatermarkWithoutImage() {
request.setWatermarkType("image");
request.setWatermarkImage(null);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark image is required"),
"Error message should mention missing watermark image");
}
@Test
@DisplayName("Should reject image watermark with empty image")
void testImageWatermarkWithEmptyImage() {
request.setWatermarkType("image");
MockMultipartFile emptyImage =
new MockMultipartFile("watermarkImage", "", "image/png", new byte[0]);
request.setWatermarkImage(emptyImage);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark image is required"),
"Error message should mention missing watermark image");
}
}
@Nested
@DisplayName("Image Type Validation Tests")
class ImageTypeValidationTests {
@Test
@DisplayName("Should accept PNG image")
void testAcceptPngImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.png", "image/png", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept JPG image")
void testAcceptJpgImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.jpg", "image/jpeg", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept JPEG image")
void testAcceptJpegImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage",
"test.jpeg",
"image/jpeg",
"image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept GIF image")
void testAcceptGifImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.gif", "image/gif", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept BMP image")
void testAcceptBmpImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.bmp", "image/bmp", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject unsupported image content type")
void testRejectUnsupportedImageContentType() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage",
"test.svg",
"image/svg+xml",
"image content".getBytes());
request.setWatermarkImage(imageFile);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Unsupported image type"),
"Error message should mention unsupported image type");
}
@Test
@DisplayName("Should reject unsupported image file extension")
void testRejectUnsupportedImageExtension() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.svg", "image/png", "image content".getBytes());
request.setWatermarkImage(imageFile);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Unsupported image file extension"),
"Error message should mention unsupported file extension");
}
}
@Nested
@DisplayName("Annotation-based Validation Tests")
class AnnotationBasedValidationTests {
@Test
@DisplayName("Should enforce count minimum of 1")
void testCountMinimum() {
// Note: This tests the @Min annotation on count field
// The actual validation happens at the framework level
request.setCount(0);
// Framework validation would reject this before reaching controller
}
@Test
@DisplayName("Should enforce count maximum of 1000")
void testCountMaximum() {
// Note: This tests the @Max annotation on count field
request.setCount(1001);
// Framework validation would reject this before reaching controller
}
@Test
@DisplayName("Should enforce rotation min/max bounds of -360 to 360")
void testRotationBounds() {
// Note: This tests the @DecimalMin/@DecimalMax annotations
request.setRotationMin(-361f);
request.setRotationMax(361f);
// Framework validation would reject this before reaching controller
}
@Test
@DisplayName("Should enforce font size bounds of 1.0 to 500.0")
void testFontSizeBounds() {
// Note: This tests the @DecimalMin/@DecimalMax annotations
request.setFontSizeMin(0.5f);
request.setFontSizeMax(501f);
// Framework validation would reject this before reaching controller
}
@Test
@DisplayName("Should enforce per-letter font count bounds of 1 to 20")
void testPerLetterFontCountBounds() {
// Note: This tests the @Min/@Max annotations
request.setPerLetterFontCount(0);
request.setPerLetterFontCount(21);
// Framework validation would reject this before reaching controller
}
@Test
@DisplayName("Should enforce per-letter color count bounds of 1 to 20")
void testPerLetterColorCountBounds() {
// Note: This tests the @Min/@Max annotations
request.setPerLetterColorCount(0);
request.setPerLetterColorCount(21);
// Framework validation would reject this before reaching controller
}
@Test
@DisplayName("Should enforce margin bounds of 0.0 to 500.0")
void testMarginBounds() {
// Note: This tests the @DecimalMin/@DecimalMax annotations
request.setMargin(-1f);
request.setMargin(501f);
// Framework validation would reject this before reaching controller
}
@Test
@DisplayName("Should enforce image scale bounds of 0.1 to 10.0")
void testImageScaleBounds() {
// Note: This tests the @DecimalMin/@DecimalMax annotations
request.setImageScale(0.05f);
request.setImageScale(11f);
// Framework validation would reject this before reaching controller
}
@Test
@DisplayName("Should validate bounds format pattern")
void testBoundsFormatPattern() {
// Note: This tests the @Pattern annotation on bounds field
request.setBounds("invalid");
// Framework validation would reject this before reaching controller
request.setBounds("100,100,200,200"); // Valid format
// Framework validation would accept this
}
}
}