This commit is contained in:
Anton Arhipov 2025-11-14 09:09:50 -05:00 committed by GitHub
commit 481e7b414d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 4511 additions and 172 deletions

View File

@ -14,6 +14,8 @@ public final class RegexPatternUtils {
private static final String WHITESPACE_REGEX = "\\s++";
private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$";
private static final Pattern COLOR_PATTERN =
Pattern.compile("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$");
private RegexPatternUtils() {
super();
@ -310,6 +312,10 @@ public final class RegexPatternUtils {
return EXTENSION_REGEX;
}
public static Pattern getColorPattern() {
return COLOR_PATTERN;
}
/** Pattern for extracting non-numeric characters */
public Pattern getNumericExtractionPattern() {
return getPattern("\\D");

View File

@ -0,0 +1,359 @@
package stirling.software.common.util;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* Utility class for generating randomized watermark attributes with deterministic (seedable)
* randomness. Supports position, rotation, mirroring, font selection, size, color, and shading.
*/
public class WatermarkRandomizer {
public static final Color[] PALETTE =
new Color[] {
Color.BLACK,
Color.DARK_GRAY,
Color.GRAY,
Color.LIGHT_GRAY,
Color.RED,
Color.BLUE,
Color.GREEN,
Color.ORANGE,
Color.MAGENTA,
Color.CYAN,
Color.PINK,
Color.YELLOW
};
private final Random random;
/**
* Creates a WatermarkRandomizer with an optional seed for deterministic randomness.
*
* @param seed Optional seed value; if null, uses non-deterministic randomness
*/
public WatermarkRandomizer(Long seed) {
this.random = (seed != null) ? new Random(seed) : new Random();
}
/**
* Generates a random position within the page.
*
* @param pageWidth Width of the page
* @param pageHeight Height of the page
* @param watermarkWidth Width of the watermark
* @param watermarkHeight Height of the watermark
* @return Array with [x, y] coordinates
*/
public float[] generateRandomPosition(
float pageWidth, float pageHeight, float watermarkWidth, float watermarkHeight) {
// Calculate available space
float maxX = Math.max(0, pageWidth - watermarkWidth);
float maxY = Math.max(0, pageHeight - watermarkHeight);
// Generate random position within page
float x = random.nextFloat() * maxX;
float y = random.nextFloat() * maxY;
return new float[] {x, y};
}
/**
* Generates multiple random positions with collision detection to ensure minimum spacing.
*
* <p>This method uses collision detection to ensure that each watermark maintains minimum
* separation from all previously placed watermarks. If a valid position cannot be found after
* multiple attempts, the method will still return the requested count but some positions may
* not satisfy spacing constraints.
*
* @param pageWidth Width of the page
* @param pageHeight Height of the page
* @param watermarkWidth Width of the watermark
* @param watermarkHeight Height of the watermark
* @param widthSpacer Horizontal spacing between watermarks (minimum separation)
* @param heightSpacer Vertical spacing between watermarks (minimum separation)
* @param count Number of positions to generate
* @return List of [x, y] coordinate arrays
*/
public List<float[]> generateRandomPositions(
float pageWidth,
float pageHeight,
float watermarkWidth,
float watermarkHeight,
int widthSpacer,
int heightSpacer,
int count) {
List<float[]> positions = new ArrayList<>();
float maxX = Math.max(0, pageWidth - watermarkWidth);
float maxY = Math.max(0, pageHeight - watermarkHeight);
// Prevent infinite loops with a maximum attempts limit
int maxAttempts = count * 10;
int attempts = 0;
while (positions.size() < count && attempts < maxAttempts) {
// Generate a random position
float x = random.nextFloat() * maxX;
float y = random.nextFloat() * maxY;
// Check if position maintains minimum spacing from existing positions
boolean validPosition = true;
if (widthSpacer > 0 || heightSpacer > 0) {
for (float[] existing : positions) {
float dx = Math.abs(x - existing[0]);
float dy = Math.abs(y - existing[1]);
// Check if the new position overlaps or violates spacing constraints
// Two watermarks violate spacing if their bounding boxes (including spacers)
// overlap
if (dx < (watermarkWidth + widthSpacer)
&& dy < (watermarkHeight + heightSpacer)) {
validPosition = false;
break;
}
}
}
if (validPosition) {
positions.add(new float[] {x, y});
}
attempts++;
}
// If we couldn't generate enough positions with spacing constraints,
// fill remaining positions without spacing constraints to meet the requested count
while (positions.size() < count) {
float x = random.nextFloat() * maxX;
float y = random.nextFloat() * maxY;
positions.add(new float[] {x, y});
}
return positions;
}
/**
* Generates a list of fixed grid positions based on spacers.
*
* @param pageWidth Width of the page
* @param pageHeight Height of the page
* @param watermarkWidth Width of the watermark (includes spacing)
* @param watermarkHeight Height of the watermark (includes spacing)
* @param widthSpacer Horizontal spacing between watermarks
* @param heightSpacer Vertical spacing between watermarks
* @param count Maximum number of watermarks (0 for unlimited grid)
* @return List of [x, y] coordinate arrays
*/
public List<float[]> generateGridPositions(
float pageWidth,
float pageHeight,
float watermarkWidth,
float watermarkHeight,
int widthSpacer,
int heightSpacer,
int count) {
List<float[]> positions = new ArrayList<>();
// Calculate automatic margins to keep watermarks within page boundaries
float maxX = Math.max(0, pageWidth - watermarkWidth);
float maxY = Math.max(0, pageHeight - watermarkHeight);
// Calculate how many rows and columns can fit within the page
// Note: watermarkWidth/Height are the actual watermark dimensions (not including spacers)
// We need to account for the spacing between watermarks when calculating grid capacity
int maxRows = (int) Math.floor(maxY / (watermarkHeight + heightSpacer));
int maxCols = (int) Math.floor(maxX / (watermarkWidth + widthSpacer));
if (count == 0) {
// Unlimited grid: fill page using spacer-based grid
// Ensure watermarks stay within visible area
for (int i = 0; i < maxRows; i++) {
for (int j = 0; j < maxCols; j++) {
float x = j * (watermarkWidth + widthSpacer);
float y = i * (watermarkHeight + heightSpacer);
// Clamp to ensure within bounds
x = Math.min(x, maxX);
y = Math.min(y, maxY);
positions.add(new float[] {x, y});
}
}
} else {
// Limited count: distribute evenly across page
// Calculate optimal distribution based on page aspect ratio
// Don't use spacer-based limits; instead ensure positions fit within maxX/maxY
int cols = (int) Math.ceil(Math.sqrt(count * pageWidth / pageHeight));
int rows = (int) Math.ceil((double) count / cols);
// Calculate spacing to distribute watermarks evenly within the visible area
// Account for watermark dimensions to prevent overflow at edges
float xSpacing = (cols > 1) ? maxX / (cols - 1) : 0;
float ySpacing = (rows > 1) ? maxY / (rows - 1) : 0;
int generated = 0;
for (int i = 0; i < rows && generated < count; i++) {
for (int j = 0; j < cols && generated < count; j++) {
float x = j * xSpacing;
float y = i * ySpacing;
// Clamp to ensure within bounds
x = Math.min(x, maxX);
y = Math.min(y, maxY);
positions.add(new float[] {x, y});
generated++;
}
}
}
return positions;
}
/**
* Generates a random rotation angle within the specified range.
*
* @param rotationMin Minimum rotation angle in degrees
* @param rotationMax Maximum rotation angle in degrees
* @return Random rotation angle in degrees
*/
public float generateRandomRotation(float rotationMin, float rotationMax) {
if (rotationMin == rotationMax) {
return rotationMin;
}
return rotationMin + random.nextFloat() * (rotationMax - rotationMin);
}
/**
* Determines whether to mirror based on probability.
*
* @param probability Probability of mirroring (0.0 to 1.0)
* @return true if should mirror, false otherwise
*/
public boolean shouldMirror(float probability) {
return random.nextFloat() < probability;
}
/**
* Selects a random font from the available font list.
*
* @param availableFonts List of available font names
* @return Random font name from the list
*/
public String selectRandomFont(List<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) {
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 color from a limited palette.
*
* @param colorCount Number of colors to select from (1-12 from predefined palette)
* @return Random Color object from the limited palette
*/
public Color generateRandomColorFromPalette(int colorCount) {
// Limit to requested count
int actualCount = Math.min(colorCount, PALETTE.length);
actualCount = Math.max(1, actualCount); // At least 1
return PALETTE[random.nextInt(actualCount)];
}
/**
* Selects a random font from a limited list of available fonts.
*
* @param fontCount Number of fonts to select from
* @return Random font name
*/
public String selectRandomFontFromCount(int fontCount) {
// Predefined list of common PDF fonts
String[] availableFonts = {
"Helvetica",
"Times-Roman",
"Courier",
"Helvetica-Bold",
"Times-Bold",
"Courier-Bold",
"Helvetica-Oblique",
"Times-Italic",
"Courier-Oblique",
"Symbol",
"ZapfDingbats"
};
// Limit to requested count
int actualCount = Math.min(fontCount, availableFonts.length);
actualCount = Math.max(1, actualCount); // At least 1
return availableFonts[random.nextInt(actualCount)];
}
/**
* Generates a random rotation for per-letter orientation within specified range.
*
* @param minRotation Minimum rotation angle in degrees
* @param maxRotation Maximum rotation angle in degrees
* @return Random rotation angle in degrees
*/
public float generatePerLetterRotationInRange(float minRotation, float maxRotation) {
if (minRotation == maxRotation) {
return minRotation;
}
return minRotation + random.nextFloat() * (maxRotation - minRotation);
}
/**
* Gets the underlying Random instance for advanced use cases.
*
* @return The Random instance
*/
public Random getRandom() {
return random;
}
}

View File

@ -1,5 +1,7 @@
package stirling.software.SPDF.controller.api.security;
import static stirling.software.common.util.RegexPatternUtils.getColorPattern;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.beans.PropertyEditorSupport;
@ -15,13 +17,18 @@ import org.apache.commons.io.IOUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.util.Matrix;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
@ -38,14 +45,13 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.common.util.*;
@Slf4j
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@ -66,6 +72,162 @@ public class WatermarkController {
});
}
/**
* Validates watermark request parameters and enforces safety caps. Throws
* IllegalArgumentException with descriptive messages for validation failures.
*/
private void validateWatermarkRequest(AddWatermarkRequest request) {
// Validate opacity bounds (0.0 - 1.0)
float opacity = request.getOpacity();
if (opacity < 0.0f || opacity > 1.0f) {
log.error("Opacity must be between 0.0 and 1.0, but got: {}", opacity);
throw ExceptionUtils.createIllegalArgumentException(
"error.opacityOutOfRange", // TODO
"Opacity must be between 0.0 and 1.0, but got: {0}",
opacity);
}
// Validate rotation range: rotationMin <= rotationMax
Float rotationMin = request.getRotationMin();
Float rotationMax = request.getRotationMax();
if (rotationMin != null && rotationMax != null && rotationMin > rotationMax) {
log.error(
"Rotation minimum ({}) must be less than or equal to rotation maximum ({})",
rotationMin,
rotationMax);
throw ExceptionUtils.createIllegalArgumentException(
"error.rotationRangeInvalid", // TODO
"Rotation minimum ({0}) must be less than or equal to rotation maximum ({1})",
rotationMin,
rotationMax);
}
// Validate font size range: fontSizeMin <= fontSizeMax
Float fontSizeMin = request.getFontSizeMin();
Float fontSizeMax = request.getFontSizeMax();
if (fontSizeMin != null && fontSizeMax != null && fontSizeMin > fontSizeMax) {
log.error(
"Font size minimum ({}) must be less than or equal to font size maximum ({})",
fontSizeMin,
fontSizeMax);
throw ExceptionUtils.createIllegalArgumentException(
"error.fontSizeRangeInvalid", // TODO
"Font size minimum ({0}) must be less than or equal to font size maximum ({1})",
fontSizeMin,
fontSizeMax);
}
// Validate color format when not using random color
String customColor = request.getCustomColor();
Boolean randomColor = request.getRandomColor();
if (customColor != null && !Boolean.TRUE.equals(randomColor)) {
// Check if color is valid hex format (#RRGGBB or #RRGGBBAA)
if (!getColorPattern().matcher(customColor).matches()) {
log.error(
"Invalid color format: {}. Expected hex format like #RRGGBB or #RRGGBBAA",
customColor);
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidColorFormat", // TODO
"Invalid color format: {0}. Expected hex format like #RRGGBB or #RRGGBBAA",
customColor);
}
}
// Validate mirroring probability bounds (0.0 - 1.0)
Float mirroringProbability = request.getMirroringProbability();
if (mirroringProbability != null
&& (mirroringProbability < 0.0f || mirroringProbability > 1.0f)) {
log.error(
"Mirroring probability must be between 0.0 and 1.0, but got: {}",
mirroringProbability);
throw ExceptionUtils.createIllegalArgumentException(
"error.mirroringProbabilityOutOfRange", // TODO
"Mirroring probability must be between 0.0 and 1.0, but got: {0}",
mirroringProbability);
}
// Validate watermark type
String watermarkType = request.getWatermarkType();
if (watermarkType == null
|| (!watermarkType.equalsIgnoreCase("text")
&& !watermarkType.equalsIgnoreCase("image"))) {
log.error("Watermark type must be 'text' or 'image', but got: {}", watermarkType);
throw ExceptionUtils.createIllegalArgumentException(
"error.unsupportedWatermarkType", // TODO
"Watermark type must be ''text'' or ''image'', but got: {0}", // single quotes
// must be escaped
watermarkType);
}
// Validate text watermark has text
if ("text".equalsIgnoreCase(watermarkType)) {
String watermarkText = request.getWatermarkText();
if (watermarkText == null || watermarkText.trim().isEmpty()) {
log.error("Watermark text is required when watermark type is 'text'");
throw ExceptionUtils.createIllegalArgumentException(
"error.watermarkTextRequired", // TODO
"Watermark text is required when watermark type is 'text'");
}
}
// Validate image watermark has image
if ("image".equalsIgnoreCase(watermarkType)) {
MultipartFile watermarkImage = request.getWatermarkImage();
if (watermarkImage == null || watermarkImage.isEmpty()) {
log.error("Watermark image is required when watermark type is 'image'");
throw ExceptionUtils.createIllegalArgumentException(
"error.watermarkImageRequired", // TODO
"Watermark image is required when watermark type is 'image'");
}
// Validate image type - only allow common image formats
String contentType = watermarkImage.getContentType();
String originalFilename = watermarkImage.getOriginalFilename();
if (contentType != null && !isSupportedImageType(contentType)) {
log.error(
"Unsupported image type: {}. Supported types: PNG, JPG, JPEG, GIF, BMP",
contentType);
throw ExceptionUtils.createIllegalArgumentException(
"error.unsupportedContentType", // TODO
"Unsupported image type: {0}. Supported types: PNG, JPG, JPEG, GIF, BMP",
contentType);
}
// Additional check based on file extension
if (originalFilename != null && !hasSupportedImageExtension(originalFilename)) {
log.error(
"Unsupported image file extension in: {}. Supported extensions: .png, .jpg, .jpeg, .gif, .bmp",
originalFilename);
throw ExceptionUtils.createIllegalArgumentException(
"error.unsupportedImageFileType", // TODO
"Unsupported image file extension in: {0}. Supported extensions: .png, .jpg, .jpeg, .gif, .bmp",
originalFilename);
}
}
log.debug("Watermark request validation passed");
}
/** Checks if the content type is a supported image format. */
private boolean isSupportedImageType(String contentType) {
return contentType.equals("image/png")
|| contentType.equals("image/jpeg")
|| contentType.equals("image/jpg")
|| contentType.equals("image/gif")
|| contentType.equals("image/bmp")
|| contentType.equals("image/x-ms-bmp");
}
/** Checks if the filename has a supported image extension. */
private boolean hasSupportedImageExtension(String filename) {
String lowerFilename = filename.toLowerCase();
return lowerFilename.endsWith(".png")
|| lowerFilename.endsWith(".jpg")
|| lowerFilename.endsWith(".jpeg")
|| lowerFilename.endsWith(".gif")
|| lowerFilename.endsWith(".bmp");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-watermark")
@Operation(
summary = "Add watermark to a PDF file",
@ -74,73 +236,85 @@ public class WatermarkController {
+ " watermark type (text or image), rotation, opacity, width spacer, and"
+ " height spacer. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<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
boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage());
// Create a randomizer with optional seed for deterministic behavior
WatermarkRandomizer randomizer = new WatermarkRandomizer(request.getSeed());
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
// Create a page in the document
// Create a PDFormXObject to cache watermarks (Solution 5: PDFormXObject caching)
PDFormXObject watermarkForm = null;
// Iterate through pages
for (PDPage page : document.getPages()) {
// Get the page's content stream
// Create the watermark form on the first page
if (watermarkForm == null) {
// Create form XObject with the same dimensions as the page
// Following the pattern from CertSignController
PDRectangle pageBox = page.getMediaBox();
PDStream stream = new PDStream(document);
watermarkForm = new PDFormXObject(stream);
watermarkForm.setResources(new PDResources());
watermarkForm.setFormType(1);
watermarkForm.setBBox(new PDRectangle(pageBox.getWidth(), pageBox.getHeight()));
// Create appearance stream for the form
PDAppearanceStream appearanceStream =
new PDAppearanceStream(watermarkForm.getCOSObject());
// Create a content stream for the appearance
PDPageContentStream formStream =
new PDPageContentStream(document, appearanceStream);
// Set transparency in the form
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(request.getOpacity());
formStream.setGraphicsStateParameters(graphicsState);
// Render watermarks into the form
if ("text".equalsIgnoreCase(watermarkType)) {
addTextWatermark(formStream, document, page, request, randomizer);
} else if ("image".equalsIgnoreCase(watermarkType)) {
addImageWatermark(formStream, document, page, request, randomizer);
}
// Close the form stream
formStream.close();
}
// Draw the cached form on the current page
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
// Set transparency
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(opacity);
contentStream.setGraphicsStateParameters(graphicsState);
if ("text".equalsIgnoreCase(watermarkType)) {
addTextWatermark(
contentStream,
watermarkText,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize,
alphabet,
customColor);
} else if ("image".equalsIgnoreCase(watermarkType)) {
addImageWatermark(
contentStream,
watermarkImage,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize);
}
// Close the content stream
contentStream.drawForm(watermarkForm);
contentStream.close();
}
@ -158,19 +332,60 @@ public class WatermarkController {
private void addTextWatermark(
PDPageContentStream contentStream,
String watermarkText,
PDDocument document,
PDPage page,
float rotation,
int widthSpacer,
int heightSpacer,
float fontSize,
String alphabet,
String colorString)
AddWatermarkRequest request,
WatermarkRandomizer randomizer)
throws IOException {
String resourceDir = "";
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
resourceDir =
String watermarkText = request.getWatermarkText();
String alphabet = request.getAlphabet();
String colorString = request.getCustomColor();
float rotation = request.getRotation();
int widthSpacer = request.getWidthSpacer();
int heightSpacer = request.getHeightSpacer();
float fontSize = request.getFontSize();
// Extract new fields with defaults
int count = (request.getCount() != null) ? request.getCount() : 1;
boolean randomPosition = Boolean.TRUE.equals(request.getRandomPosition());
boolean randomFont = Boolean.TRUE.equals(request.getRandomFont());
boolean randomColor = Boolean.TRUE.equals(request.getRandomColor());
boolean perLetterFont = Boolean.TRUE.equals(request.getPerLetterFont());
boolean perLetterColor = Boolean.TRUE.equals(request.getPerLetterColor());
boolean perLetterSize = Boolean.TRUE.equals(request.getPerLetterSize());
boolean perLetterOrientation = Boolean.TRUE.equals(request.getPerLetterOrientation());
boolean shadingRandom = Boolean.TRUE.equals(request.getShadingRandom());
String shading = request.getShading();
float rotationMin =
(request.getRotationMin() != null) ? request.getRotationMin() : rotation;
float rotationMax =
(request.getRotationMax() != null) ? request.getRotationMax() : rotation;
float fontSizeMin =
(request.getFontSizeMin() != null) ? request.getFontSizeMin() : fontSize;
float fontSizeMax =
(request.getFontSizeMax() != null) ? request.getFontSizeMax() : fontSize;
// Extract per-letter configuration with defaults
int perLetterFontCount =
(request.getPerLetterFontCount() != null) ? request.getPerLetterFontCount() : 2;
int perLetterColorCount =
(request.getPerLetterColorCount() != null) ? request.getPerLetterColorCount() : 4;
float perLetterSizeMin =
(request.getPerLetterSizeMin() != null) ? request.getPerLetterSizeMin() : 10f;
float perLetterSizeMax =
(request.getPerLetterSizeMax() != null) ? request.getPerLetterSizeMax() : 100f;
float perLetterOrientationMin =
(request.getPerLetterOrientationMin() != null)
? request.getPerLetterOrientationMin()
: 0f;
float perLetterOrientationMax =
(request.getPerLetterOrientationMax() != null)
? request.getPerLetterOrientationMax()
: 360f;
String resourceDir =
switch (alphabet) {
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
case "japanese" -> "static/fonts/Meiryo.ttf";
@ -183,6 +398,8 @@ public class WatermarkController {
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
PDFont font;
try (InputStream is = classPathResource.getInputStream();
FileOutputStream os = new FileOutputStream(tempFile)) {
IOUtils.copy(is, os);
@ -191,63 +408,184 @@ public class WatermarkController {
Files.deleteIfExists(tempFile.toPath());
}
contentStream.setFont(font, fontSize);
Color redactColor;
try {
if (!colorString.startsWith("#")) {
colorString = "#" + colorString;
}
redactColor = Color.decode(colorString);
} catch (NumberFormatException e) {
redactColor = Color.LIGHT_GRAY;
}
contentStream.setNonStrokingColor(redactColor);
String[] textLines =
RegexPatternUtils.getInstance().getEscapedNewlinePattern().split(watermarkText);
float maxLineWidth = 0;
for (int i = 0; i < textLines.length; ++i) {
maxLineWidth = Math.max(maxLineWidth, font.getStringWidth(textLines[i]));
}
// Set size and location of text watermark
float watermarkWidth = widthSpacer + maxLineWidth * fontSize / 1000;
float watermarkHeight = heightSpacer + fontSize * textLines.length;
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
// Calculating the new width and height depending on the angle.
float radians = (float) Math.toRadians(rotation);
float newWatermarkWidth =
(float)
(Math.abs(watermarkWidth * Math.cos(radians))
+ Math.abs(watermarkHeight * Math.sin(radians)));
float newWatermarkHeight =
(float)
(Math.abs(watermarkWidth * Math.sin(radians))
+ Math.abs(watermarkHeight * Math.cos(radians)));
// Determine positions based on a randomPosition flag
java.util.List<float[]> positions;
// Calculating the number of rows and columns.
// Calculate approximate watermark dimensions for positioning
// Estimate width based on average character width (more accurate than fixed 100)
float avgCharWidth = fontSize * 0.6f; // Approximate average character width
float maxLineWidth = 0;
for (String line : textLines) {
float lineWidth = line.length() * avgCharWidth;
if (lineWidth > maxLineWidth) {
maxLineWidth = lineWidth;
}
}
float watermarkWidth = maxLineWidth;
float watermarkHeight = fontSize * textLines.length;
int watermarkRows = (int) (pageHeight / newWatermarkHeight + 1);
int watermarkCols = (int) (pageWidth / newWatermarkWidth + 1);
if (randomPosition) {
// Generate random positions with collision detection
positions =
randomizer.generateRandomPositions(
pageWidth,
pageHeight,
watermarkWidth,
watermarkHeight,
widthSpacer,
heightSpacer,
count);
} else {
// Generate grid positions (backward compatible)
positions =
randomizer.generateGridPositions(
pageWidth,
pageHeight,
watermarkWidth,
watermarkHeight,
widthSpacer,
heightSpacer,
count);
}
// Define available fonts for random selection
java.util.List<String> availableFonts =
java.util.Arrays.asList(
"Helvetica",
"Times-Roman",
"Courier",
"Helvetica-Bold",
"Times-Bold",
"Courier-Bold");
// Render each watermark instance
for (float[] pos : positions) {
float x = pos[0];
float y = pos[1];
// Determine the font for this watermark instance
PDFont wmFont;
if (randomFont) {
try {
String selectedFontName = randomizer.selectRandomFont(availableFonts);
wmFont = new PDType1Font(Standard14Fonts.getMappedFontName(selectedFontName));
} catch (Exception e) {
log.warn("Failed to load random font, using base font instead", e);
wmFont = font; // Fall back to the base font loaded earlier
}
} else {
wmFont = font; // Use the base font loaded from alphabet selection
}
// Determine rotation for this watermark
float wmRotation = randomizer.generateRandomRotation(rotationMin, rotationMax);
// Determine font size for this watermark
float wmFontSize = randomizer.generateRandomFontSize(fontSizeMin, fontSizeMax);
// Determine color for this watermark
Color wmColor;
if (randomColor) {
wmColor = randomizer.generateRandomColor(true);
} else {
try {
String colorStr = colorString;
if (!colorStr.startsWith("#")) {
colorStr = "#" + colorStr;
}
wmColor = Color.decode(colorStr);
} catch (Exception e) {
wmColor = Color.LIGHT_GRAY;
}
}
// Determine and apply shading style
String wmShading =
shadingRandom
? randomizer.selectRandomShading(
java.util.Arrays.asList("none", "light", "dark"))
: (shading != null ? shading : "none");
// Apply shading by adjusting color intensity
wmColor = applyShadingToColor(wmColor, wmShading);
// Render text with per-letter variations if enabled
if (perLetterFont || perLetterColor || perLetterSize || perLetterOrientation) {
renderTextWithPerLetterVariations(
contentStream,
textLines,
wmFont,
wmFontSize,
wmColor,
wmRotation,
x,
y,
perLetterFont,
perLetterColor,
perLetterSize,
perLetterOrientation,
perLetterSizeMin,
perLetterSizeMax,
perLetterFontCount,
perLetterColorCount,
perLetterOrientationMin,
perLetterOrientationMax,
randomizer);
} else {
// Standard rendering without per-letter variations
contentStream.setFont(wmFont, wmFontSize);
contentStream.setNonStrokingColor(wmColor);
// Calculate actual watermark dimensions for center-point rotation
float actualMaxLineWidth = 0;
for (String textLine : textLines) {
float lineWidth = wmFont.getStringWidth(textLine) * wmFontSize / 1000;
if (lineWidth > actualMaxLineWidth) {
actualMaxLineWidth = lineWidth;
}
}
float actualWatermarkWidth = actualMaxLineWidth;
float actualWatermarkHeight = wmFontSize * textLines.length;
// Calculate center point of the watermark
float centerX = x + actualWatermarkWidth / 2;
float centerY = y + actualWatermarkHeight / 2;
// Apply rotation around center point
// To rotate around center, we need to:
// 1. Calculate offset from center to bottom-left corner
// 2. Rotate that offset
// 3. Add rotated offset to center to get final position
double rotationRad = Math.toRadians(wmRotation);
double cos = Math.cos(rotationRad);
double sin = Math.sin(rotationRad);
// Offset from center to bottom-left corner (where text starts)
float offsetX = -actualWatermarkWidth / 2;
float offsetY = -actualWatermarkHeight / 2;
// Apply rotation to the offset
float rotatedOffsetX = (float) (offsetX * cos - offsetY * sin);
float rotatedOffsetY = (float) (offsetX * sin + offsetY * cos);
// Final position where text should start
float finalX = centerX + rotatedOffsetX;
float finalY = centerY + rotatedOffsetY;
// Add the text watermark
for (int i = 0; i <= watermarkRows; i++) {
for (int j = 0; j <= watermarkCols; j++) {
contentStream.beginText();
contentStream.setTextMatrix(
Matrix.getRotateInstance(
(float) Math.toRadians(rotation),
j * newWatermarkWidth,
i * newWatermarkHeight));
(float) Math.toRadians(wmRotation), finalX, finalY));
for (int k = 0; k < textLines.length; ++k) {
contentStream.showText(textLines[k]);
contentStream.newLineAtOffset(0, -fontSize);
for (String textLine : textLines) {
contentStream.showText(textLine);
contentStream.newLineAtOffset(0, -wmFontSize);
}
contentStream.endText();
@ -255,25 +593,168 @@ public class WatermarkController {
}
}
private void renderTextWithPerLetterVariations(
PDPageContentStream contentStream,
String[] textLines,
PDFont baseFont,
float baseFontSize,
Color baseColor,
float baseRotation,
float startX,
float startY,
boolean perLetterFont,
boolean perLetterColor,
boolean perLetterSize,
boolean perLetterOrientation,
float fontSizeMin,
float fontSizeMax,
int perLetterFontCount,
int perLetterColorCount,
float perLetterOrientationMin,
float perLetterOrientationMax,
WatermarkRandomizer randomizer)
throws IOException {
// Convert base rotation to radians for trigonometric calculations
double baseRotationRad = Math.toRadians(baseRotation);
double cosBase = Math.cos(baseRotationRad);
double sinBase = Math.sin(baseRotationRad);
float currentLineOffset = 0; // Vertical offset for each line
for (String line : textLines) {
float currentCharOffset = 0; // Horizontal offset along the baseline for current line
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
String charStr = String.valueOf(c);
// Determine per-letter attributes
float letterSize =
perLetterSize
? randomizer.generateRandomFontSize(fontSizeMin, fontSizeMax)
: baseFontSize;
Color letterColor =
perLetterColor
? randomizer.generateRandomColorFromPalette(perLetterColorCount)
: baseColor;
// Calculate per-letter rotation: base rotation + additional per-letter variation
float additionalRotation =
perLetterOrientation
? randomizer.generatePerLetterRotationInRange(
perLetterOrientationMin, perLetterOrientationMax)
: 0f;
float letterRotation = baseRotation + additionalRotation;
// Determine per-letter font
PDFont letterFont = baseFont;
if (perLetterFont) {
try {
String randomFontName =
randomizer.selectRandomFontFromCount(perLetterFontCount);
letterFont =
new PDType1Font(Standard14Fonts.getMappedFontName(randomFontName));
} catch (Exception e) {
// Fall back to base font if font loading fails
log.warn("Failed to load random font, using base font instead", e);
}
}
// Calculate letter dimensions for center-point rotation
float charWidth = letterFont.getStringWidth(charStr) * letterSize / 1000;
float charHeight = letterSize; // Approximate height as font size
// Calculate the position of this letter along the rotated baseline
// Transform from local coordinates (along baseline) to page coordinates
float localX = currentCharOffset;
float localY = -currentLineOffset; // Negative because text lines go downward
// Apply rotation transformation to position the letter on the rotated baseline
float transformedX = (float) (startX + localX * cosBase - localY * sinBase);
float transformedY = (float) (startY + localX * sinBase + localY * cosBase);
// Calculate letter's center point on the rotated baseline
float letterCenterX = transformedX + charWidth / 2;
float letterCenterY = transformedY + charHeight / 2;
// Apply rotation around letter center
// Calculate offset from center to bottom-left corner
double letterRotationRad = Math.toRadians(letterRotation);
double cosLetter = Math.cos(letterRotationRad);
double sinLetter = Math.sin(letterRotationRad);
float offsetX = -charWidth / 2;
float offsetY = -charHeight / 2;
// Rotate the offset
float rotatedOffsetX = (float) (offsetX * cosLetter - offsetY * sinLetter);
float rotatedOffsetY = (float) (offsetX * sinLetter + offsetY * cosLetter);
// Final position where letter should be rendered
float finalLetterX = letterCenterX + rotatedOffsetX;
float finalLetterY = letterCenterY + rotatedOffsetY;
// Set font and color
contentStream.setFont(letterFont, letterSize);
contentStream.setNonStrokingColor(letterColor);
// Render the character at the transformed position with combined rotation
contentStream.beginText();
contentStream.setTextMatrix(
Matrix.getRotateInstance(
(float) Math.toRadians(letterRotation),
finalLetterX,
finalLetterY));
contentStream.showText(charStr);
contentStream.endText();
// Advance position along the baseline for the next character
currentCharOffset += charWidth;
}
// Move to next line (advance vertically in local coordinates)
currentLineOffset += baseFontSize;
}
}
private void addImageWatermark(
PDPageContentStream contentStream,
MultipartFile watermarkImage,
PDDocument document,
PDPage page,
float rotation,
int widthSpacer,
int heightSpacer,
float fontSize)
AddWatermarkRequest request,
WatermarkRandomizer randomizer)
throws IOException {
MultipartFile watermarkImage = request.getWatermarkImage();
float rotation = request.getRotation();
int widthSpacer = request.getWidthSpacer();
int heightSpacer = request.getHeightSpacer();
float fontSize = request.getFontSize();
// Extract new fields with defaults
int count = (request.getCount() != null) ? request.getCount() : 1;
boolean randomPosition = Boolean.TRUE.equals(request.getRandomPosition());
boolean randomMirroring = Boolean.TRUE.equals(request.getRandomMirroring());
float mirroringProbability =
(request.getMirroringProbability() != null)
? request.getMirroringProbability()
: 0.5f;
float imageScale = (request.getImageScale() != null) ? request.getImageScale() : 1.0f;
float rotationMin =
(request.getRotationMin() != null) ? request.getRotationMin() : rotation;
float rotationMax =
(request.getRotationMax() != null) ? request.getRotationMax() : rotation;
// Load the watermark image
BufferedImage image = ImageIO.read(watermarkImage.getInputStream());
// Compute width based on original aspect ratio
// Compute width based on an original aspect ratio
float aspectRatio = (float) image.getWidth() / (float) image.getHeight();
// Desired physical height (in PDF points)
float desiredPhysicalHeight = fontSize;
// Desired physical height (in PDF points) with scale applied
float desiredPhysicalHeight = fontSize * imageScale;
// Desired physical width based on the aspect ratio
float desiredPhysicalWidth = desiredPhysicalHeight * aspectRatio;
@ -281,35 +762,102 @@ public class WatermarkController {
// Convert the BufferedImage to PDImageXObject
PDImageXObject xobject = LosslessFactory.createFromImage(document, image);
// Calculate the number of rows and columns for watermarks
// Get page dimensions
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
int watermarkRows =
(int) ((pageHeight + heightSpacer) / (desiredPhysicalHeight + heightSpacer));
int watermarkCols =
(int) ((pageWidth + widthSpacer) / (desiredPhysicalWidth + widthSpacer));
for (int i = 0; i < watermarkRows; i++) {
for (int j = 0; j < watermarkCols; j++) {
float x = j * (desiredPhysicalWidth + widthSpacer);
float y = i * (desiredPhysicalHeight + heightSpacer);
// Determine positions based on a randomPosition flag
java.util.List<float[]> positions;
if (randomPosition) {
// Generate random positions with collision detection
positions =
randomizer.generateRandomPositions(
pageWidth,
pageHeight,
desiredPhysicalWidth,
desiredPhysicalHeight,
widthSpacer,
heightSpacer,
count);
} else {
// Generate grid positions (backward compatible)
positions =
randomizer.generateGridPositions(
pageWidth,
pageHeight,
desiredPhysicalWidth,
desiredPhysicalHeight,
widthSpacer,
heightSpacer,
count);
}
// Save the graphics state
contentStream.saveGraphicsState();
// Render each watermark instance
for (float[] pos : positions) {
float x = pos[0];
float y = pos[1];
// Create rotation matrix and rotate
contentStream.transform(
Matrix.getTranslateInstance(
x + desiredPhysicalWidth / 2, y + desiredPhysicalHeight / 2));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.transform(
Matrix.getTranslateInstance(
-desiredPhysicalWidth / 2, -desiredPhysicalHeight / 2));
// Determine rotation for this watermark
float wmRotation = randomizer.generateRandomRotation(rotationMin, rotationMax);
// Draw the image and restore the graphics state
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.restoreGraphicsState();
// Determine if this watermark should be mirrored
boolean shouldMirror = randomMirroring && randomizer.shouldMirror(mirroringProbability);
// Save the graphics state
contentStream.saveGraphicsState();
// Translate to center of image position
contentStream.transform(
Matrix.getTranslateInstance(
x + desiredPhysicalWidth / 2, y + desiredPhysicalHeight / 2));
// Apply rotation
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(wmRotation), 0, 0));
// Apply mirroring if needed (horizontal flip)
if (shouldMirror) {
contentStream.transform(Matrix.getScaleInstance(-1, 1));
}
// Translate back to draw from corner
contentStream.transform(
Matrix.getTranslateInstance(
-desiredPhysicalWidth / 2, -desiredPhysicalHeight / 2));
// Draw the image and restore the graphics state
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.restoreGraphicsState();
}
}
/**
* Applies shading to a color by adjusting its intensity.
*
* @param color Original color
* @param shading Shading style: "none", "light", or "dark"
* @return Color with shading applied
*/
private Color applyShadingToColor(Color color, String shading) {
if (shading == null || "none".equalsIgnoreCase(shading)) {
return color;
}
int r = color.getRed();
int g = color.getGreen();
int b = color.getBlue();
if ("light".equalsIgnoreCase(shading)) {
// Lighten the color by moving towards white
r = r + (255 - r) / 2;
g = g + (255 - g) / 2;
b = b + (255 - b) / 2;
} else if ("dark".equalsIgnoreCase(shading)) {
// Darken the color by moving towards black
r = r / 2;
g = g / 2;
b = b / 2;
}
return new Color(r, g, b);
}
}

View File

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

View File

@ -1583,6 +1583,61 @@ watermark.selectText.10=Convert PDF to PDF-Image
watermark.submit=Add Watermark
watermark.type.1=Text
watermark.type.2=Image
watermark.advanced.title=Advanced Options
watermark.advanced.count.label=Number of Watermarks
watermark.advanced.count.help=Number of watermark instances per page (1-1000)
watermark.advanced.position.label=Random Positioning
watermark.advanced.position.help=Enable random placement instead of grid layout
watermark.advanced.rotation.mode.label=Rotation Mode
watermark.advanced.rotation.mode.fixed=Fixed Angle
watermark.advanced.rotation.mode.range=Random Range
watermark.advanced.rotation.min.label=Rotation Min (degrees)
watermark.advanced.rotation.max.label=Rotation Max (degrees)
watermark.advanced.rotation.range.help=Random angle between min and max
watermark.advanced.rotation.range.error=Minimum must be less than or equal to maximum
watermark.advanced.mirroring.label=Random Mirroring
watermark.advanced.mirroring.help=For images only
watermark.advanced.mirroring.probability.label=Mirroring Probability
watermark.advanced.mirroring.probability.help=Probability of mirroring (0.0-1.0)
watermark.advanced.font.random.label=Random Font
watermark.advanced.font.random.help=Enable random font selection for text watermarks
watermark.advanced.font.name.label=Font Name
watermark.advanced.font.name.help=Leave empty for default font
watermark.advanced.font.size.mode.label=Font Size Mode
watermark.advanced.font.size.mode.fixed=Fixed Size
watermark.advanced.font.size.mode.range=Random Range
watermark.advanced.font.size.min.label=Font Size Min
watermark.advanced.font.size.max.label=Font Size Max
watermark.advanced.font.size.range.help=Random size between min and max
watermark.advanced.font.size.range.error=Minimum must be less than or equal to maximum
watermark.advanced.color.random.label=Random Color
watermark.advanced.perLetter.title=Per-Letter Variations
watermark.advanced.perLetter.font.label=Vary Font per Letter
watermark.advanced.perLetter.font.count.label=Number of Fonts
watermark.advanced.perLetter.font.count.help=Number of fonts to randomly select from (1-20, default: 2)
watermark.advanced.perLetter.color.label=Vary Color per Letter
watermark.advanced.perLetter.color.count.label=Number of Colors
watermark.advanced.perLetter.color.count.help=Number of colors to randomly select from (1-12, default: 4)
watermark.advanced.perLetter.size.label=Vary Size per Letter
watermark.advanced.perLetter.size.min.label=Size Min
watermark.advanced.perLetter.size.max.label=Size Max
watermark.advanced.perLetter.size.help=Font size range (1-500, default: 10-100)
watermark.advanced.perLetter.size.error=Minimum must be less than or equal to maximum
watermark.advanced.perLetter.orientation.label=Vary Orientation per Letter
watermark.advanced.perLetter.orientation.min.label=Angle Min (degrees)
watermark.advanced.perLetter.orientation.max.label=Angle Max (degrees)
watermark.advanced.perLetter.orientation.help=Rotation angle range (-360 to 360, default: 0-360)
watermark.advanced.perLetter.orientation.error=Minimum must be less than or equal to maximum
watermark.advanced.perLetter.help=Apply variations at the letter level for dynamic appearance
watermark.advanced.shading.random.label=Random Shading
watermark.advanced.shading.label=Shading Style
watermark.advanced.shading.none=None
watermark.advanced.shading.light=Light
watermark.advanced.shading.dark=Dark
watermark.advanced.image.scale.label=Image Scale Factor
watermark.advanced.image.scale.help=Scale factor (1.0 = original size, 0.1-10.0)
watermark.advanced.seed.label=Random Seed (optional)
watermark.advanced.seed.help=For deterministic randomness (testing)
#Change permissions

View File

@ -1267,6 +1267,8 @@ flatten.flattenOnlyForms=Flatten only forms
flatten.renderDpi=Rendering DPI (optional, recommended 150 DPI):
flatten.renderDpi.help=Leave blank to use the system default. Higher DPI sharpens output but increases processing time and file size.
flatten.submit=Flatten
flatten.renderDpi=Rendering DPI (optional, recommended 150 DPI):
flatten.renderDpi.help=Leave blank to use the system default. Higher DPI sharpens output but increases processing time and file size.
#ScannerImageSplit
@ -1568,6 +1570,61 @@ watermark.selectText.10=Convert PDF to PDF-Image
watermark.submit=Add Watermark
watermark.type.1=Text
watermark.type.2=Image
watermark.advanced.title=Advanced Options
watermark.advanced.count.label=Number of Watermarks
watermark.advanced.count.help=Number of watermark instances per page (1-1000)
watermark.advanced.position.label=Random Positioning
watermark.advanced.position.help=Enable random placement instead of grid layout
watermark.advanced.rotation.mode.label=Rotation Mode
watermark.advanced.rotation.mode.fixed=Fixed Angle
watermark.advanced.rotation.mode.range=Random Range
watermark.advanced.rotation.min.label=Rotation Min (degrees)
watermark.advanced.rotation.max.label=Rotation Max (degrees)
watermark.advanced.rotation.range.help=Random angle between min and max
watermark.advanced.rotation.range.error=Minimum must be less than or equal to maximum
watermark.advanced.mirroring.label=Random Mirroring
watermark.advanced.mirroring.help=For images only
watermark.advanced.mirroring.probability.label=Mirroring Probability
watermark.advanced.mirroring.probability.help=Probability of mirroring (0.0-1.0)
watermark.advanced.font.random.label=Random Font
watermark.advanced.font.random.help=Enable random font selection for text watermarks
watermark.advanced.font.name.label=Font Name
watermark.advanced.font.name.help=Leave empty for default font
watermark.advanced.font.size.mode.label=Font Size Mode
watermark.advanced.font.size.mode.fixed=Fixed Size
watermark.advanced.font.size.mode.range=Random Range
watermark.advanced.font.size.min.label=Font Size Min
watermark.advanced.font.size.max.label=Font Size Max
watermark.advanced.font.size.range.help=Random size between min and max
watermark.advanced.font.size.range.error=Minimum must be less than or equal to maximum
watermark.advanced.color.random.label=Random Color
watermark.advanced.perLetter.title=Per-Letter Variations
watermark.advanced.perLetter.font.label=Vary Font per Letter
watermark.advanced.perLetter.font.count.label=Number of Fonts
watermark.advanced.perLetter.font.count.help=Number of fonts to randomly select from (1-20, default: 2)
watermark.advanced.perLetter.color.label=Vary Color per Letter
watermark.advanced.perLetter.color.count.label=Number of Colors
watermark.advanced.perLetter.color.count.help=Number of colors to randomly select from (1-12, default: 4)
watermark.advanced.perLetter.size.label=Vary Size per Letter
watermark.advanced.perLetter.size.min.label=Size Min
watermark.advanced.perLetter.size.max.label=Size Max
watermark.advanced.perLetter.size.help=Font size range (1-500, default: 10-100)
watermark.advanced.perLetter.size.error=Minimum must be less than or equal to maximum
watermark.advanced.perLetter.orientation.label=Vary Orientation per Letter
watermark.advanced.perLetter.orientation.min.label=Angle Min (degrees)
watermark.advanced.perLetter.orientation.max.label=Angle Max (degrees)
watermark.advanced.perLetter.orientation.help=Rotation angle range (-360 to 360, default: 0-360)
watermark.advanced.perLetter.orientation.error=Minimum must be less than or equal to maximum
watermark.advanced.perLetter.help=Apply variations at the letter level for dynamic appearance
watermark.advanced.shading.random.label=Random Shading
watermark.advanced.shading.label=Shading Style
watermark.advanced.shading.none=None
watermark.advanced.shading.light=Light
watermark.advanced.shading.dark=Dark
watermark.advanced.image.scale.label=Image Scale Factor
watermark.advanced.image.scale.help=Scale factor (1.0 = original size, 0.1-10.0)
watermark.advanced.seed.label=Random Seed (optional)
watermark.advanced.seed.help=For deterministic randomness (testing)
#Change permissions

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,55 +95,248 @@
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" th:text="#{watermark.advanced.title}">
Advanced Options
</button>
<div id="advancedOptions" class="collapse">
<!-- Watermark Count -->
<div class="mb-3">
<label for="count" th:text="#{watermark.advanced.count.label}">Number of Watermarks</label>
<input type="number" id="count" name="count" class="form-control" value="30" min="1" max="1000">
<small class="form-text text-muted" th:text="#{watermark.advanced.count.help}">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" checked>
<label for="randomPosition" th:text="#{watermark.advanced.position.label}">Random Positioning</label>
</div>
<small class="form-text text-muted" th:text="#{watermark.advanced.position.help}">Enable random placement instead of grid layout</small>
</div>
<!-- Rotation Controls -->
<div class="mb-3">
<label th:text="#{watermark.advanced.rotation.mode.label}">Rotation Mode</label>
<select class="form-control" id="rotationMode" onchange="toggleRotationMode()">
<option value="fixed" th:text="#{watermark.advanced.rotation.mode.fixed}">Fixed Angle</option>
<option value="range" th:text="#{watermark.advanced.rotation.mode.range}">Random Range</option>
</select>
</div>
<div id="rotationRangeGroup" class="mb-3" style="display: none;">
<label for="rotationMin" th:text="#{watermark.advanced.rotation.min.label}">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" th:text="#{watermark.advanced.rotation.max.label}">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" th:text="#{watermark.advanced.rotation.range.help}">Random angle between min and max</small>
<div id="rotationRangeError" class="invalid-feedback" style="display: none;" th:text="#{watermark.advanced.rotation.range.error}">
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" onchange="toggleMirroringProbability()">
<label for="randomMirroring" th:text="#{watermark.advanced.mirroring.label}">Random Mirroring</label>
<small class="form-text text-muted" th:text="#{watermark.advanced.mirroring.help}">For images only</small>
</div>
</div>
<div id="mirroringProbabilityGroup" class="mb-3" style="display: none;">
<label for="mirroringProbability" th:text="#{watermark.advanced.mirroring.probability.label}">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" th:text="#{watermark.advanced.mirroring.probability.help}">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" onchange="toggleFontMode()">
<label for="randomFont" th:text="#{watermark.advanced.font.random.label}">Random Font</label>
</div>
<small class="form-text text-muted" th:text="#{watermark.advanced.font.random.help}">Enable random font selection for text watermarks</small>
</div>
<div id="fontNameGroup" class="mb-3">
<label for="fontName" th:text="#{watermark.advanced.font.name.label}">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" th:text="#{watermark.advanced.font.name.help}">Leave empty for default font</small>
</div>
<div class="mb-3">
<label th:text="#{watermark.advanced.font.size.mode.label}">Font Size Mode</label>
<select class="form-control" id="fontSizeMode" onchange="toggleFontSizeMode()">
<option value="fixed" th:text="#{watermark.advanced.font.size.mode.fixed}">Fixed Size</option>
<option value="range" th:text="#{watermark.advanced.font.size.mode.range}">Random Range</option>
</select>
</div>
<div id="fontSizeRangeGroup" class="mb-3" style="display: none;">
<label for="fontSizeMin" th:text="#{watermark.advanced.font.size.min.label}">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" th:text="#{watermark.advanced.font.size.max.label}">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" th:text="#{watermark.advanced.font.size.range.help}">Random size between min and max</small>
<div id="fontSizeRangeError" class="invalid-feedback" style="display: none;" th:text="#{watermark.advanced.font.size.range.error}">
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" onchange="toggleColorMode()">
<label for="randomColor" th:text="#{watermark.advanced.color.random.label}">Random Color</label>
</div>
</div>
<!-- Per-Letter Variation Controls (for text watermarks) -->
<div id="perLetterGroup" class="mb-3">
<label th:text="#{watermark.advanced.perLetter.title}">Per-Letter Variations</label>
<!-- Per-Letter Font -->
<div class="form-check">
<input type="checkbox" id="perLetterFont" name="perLetterFont" onchange="togglePerLetterFontConfig()">
<label for="perLetterFont" th:text="#{watermark.advanced.perLetter.font.label}">Vary Font per Letter</label>
</div>
<div id="perLetterFontConfig" class="mb-2 ms-4" style="display: none;">
<label for="perLetterFontCount" th:text="#{watermark.advanced.perLetter.font.count.label}">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" th:text="#{watermark.advanced.perLetter.font.count.help}">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" onchange="togglePerLetterColorConfig()">
<label for="perLetterColor" th:text="#{watermark.advanced.perLetter.color.label}">Vary Color per Letter</label>
</div>
<div id="perLetterColorConfig" class="mb-2 ms-4" style="display: none;">
<label for="perLetterColorCount" th:text="#{watermark.advanced.perLetter.color.count.label}">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" th:text="#{watermark.advanced.perLetter.color.count.help}">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" onchange="togglePerLetterSizeConfig()">
<label for="perLetterSize" th:text="#{watermark.advanced.perLetter.size.label}">Vary Size per Letter</label>
</div>
<div id="perLetterSizeConfig" class="mb-2 ms-4" style="display: none;">
<label for="perLetterSizeMin" th:text="#{watermark.advanced.perLetter.size.min.label}">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" th:text="#{watermark.advanced.perLetter.size.max.label}">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" th:text="#{watermark.advanced.perLetter.size.help}">Font size range (1-500, default: 10-100)</small>
<div id="perLetterSizeError" class="invalid-feedback" style="display: none;" th:text="#{watermark.advanced.perLetter.size.error}">
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" onchange="togglePerLetterOrientationConfig()">
<label for="perLetterOrientation" th:text="#{watermark.advanced.perLetter.orientation.label}">Vary Orientation per Letter</label>
</div>
<div id="perLetterOrientationConfig" class="mb-2 ms-4" style="display: none;">
<label for="perLetterOrientationMin" th:text="#{watermark.advanced.perLetter.orientation.min.label}">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" th:text="#{watermark.advanced.perLetter.orientation.max.label}">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" th:text="#{watermark.advanced.perLetter.orientation.help}">Rotation angle range (-360 to 360, default: 0-360)</small>
<div id="perLetterOrientationError" class="invalid-feedback" style="display: none;" th:text="#{watermark.advanced.perLetter.orientation.error}">
Minimum must be less than or equal to maximum
</div>
</div>
<small class="form-text text-muted" th:text="#{watermark.advanced.perLetter.help}">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 id="shadingRandom" name="shadingRandom" type="checkbox" onchange="toggleShadingMode()">
<label for="shadingRandom" th:text="#{watermark.advanced.shading.random.label}">Random Shading</label>
</div>
</div>
<div class="form-check mb-3">
<input id="convertPDFToImage" name="convertPDFToImage" type="checkbox">
<label for="convertPDFToImage" th:text="#{watermark.selectText.10}"></label>
</div>
<div class="mb-3 text-left">
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{watermark.submit}"></button>
</div>
</form>
<div id="shadingFixedGroup" class="mb-3">
<label for="shading" th:text="#{watermark.advanced.shading.label}">Shading Style</label>
<select class="form-control" id="shading" name="shading">
<option value="none" th:text="#{watermark.advanced.shading.none}">None</option>
<option value="light" th:text="#{watermark.advanced.shading.light}">Light</option>
<option value="dark" th:text="#{watermark.advanced.shading.dark}">Dark</option>
</select>
</div>
<!-- Image Scale Control (for image watermarks) -->
<div id="imageScaleGroup" class="mb-3" style="display: none;">
<label for="imageScale" th:text="#{watermark.advanced.image.scale.label}">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" th:text="#{watermark.advanced.image.scale.help}">Scale factor (1.0 = original size, 0.1-10.0)</small>
</div>
<div class="mb-3">
<label for="seed" th:text="#{watermark.advanced.seed.label}">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" th:text="#{watermark.advanced.seed.help}">For deterministic randomness (testing)</small>
</div>
</div>
</div>
<div class="form-check mb-3">
<input id="convertPDFToImage" name="convertPDFToImage" type="checkbox">
<label for="convertPDFToImage" th:text="#{watermark.selectText.10}"></label>
</div>
<div class="mb-3 text-left">
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{watermark.submit}"></button>
</div>
</form>
<script>
function toggleFileOption() {
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 +349,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 +369,251 @@
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 < 0 || count > 1000) {
errors.push('Count must be between 0 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 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,705 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@ExtendWith(MockitoExtension.class)
@DisplayName("Watermark Controller Integration Tests")
class WatermarkControllerIntegrationTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private WatermarkController watermarkController;
private MockMultipartFile testPdfFile;
private MockMultipartFile testImageFile;
@BeforeEach
void setUp() throws IOException {
// Create a simple test PDF
PDDocument document = new PDDocument();
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
File tempPdf = File.createTempFile("test", ".pdf");
document.save(tempPdf);
document.close();
byte[] pdfBytes = Files.readAllBytes(tempPdf.toPath());
testPdfFile = new MockMultipartFile("fileInput", "test.pdf", "application/pdf", pdfBytes);
tempPdf.delete();
// Create a simple test image (1x1 pixel PNG)
byte[] imageBytes =
new byte[] {
(byte) 0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x06,
0x00,
0x00,
0x00,
0x1F,
0x15,
(byte) 0xC4,
(byte) 0x89,
0x00,
0x00,
0x00,
0x0A,
0x49,
0x44,
0x41,
0x54,
0x78,
(byte) 0x9C,
0x63,
0x00,
0x01,
0x00,
0x00,
0x05,
0x00,
0x01,
0x0D,
0x0A,
0x2D,
(byte) 0xB4,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
(byte) 0xAE,
0x42,
0x60,
(byte) 0x82
};
testImageFile =
new MockMultipartFile("watermarkImage", "test.png", "image/png", imageBytes);
// Configure mock to return a real PDDocument when load is called
when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class)))
.thenAnswer(
invocation -> {
org.springframework.web.multipart.MultipartFile file =
invocation.getArgument(0);
return Loader.loadPDF(file.getBytes());
});
}
@Nested
@DisplayName("Text Watermark Integration Tests")
class TextWatermarkIntegrationTests {
@Test
@DisplayName("Should apply text watermark with fixed positioning")
void testTextWatermarkFixedPositioning() throws Exception {
AddWatermarkRequest request = new AddWatermarkRequest();
request.setAlphabet("roman");
request.setFileInput(testPdfFile);
request.setWatermarkType("text");
request.setWatermarkText("Test Watermark");
request.setOpacity(0.5f);
request.setFontSize(30f);
request.setRotation(45f);
request.setWidthSpacer(100);
request.setHeightSpacer(100);
request.setCustomColor("#FF0000");
request.setConvertPDFToImage(false);
request.setRandomPosition(false);
ResponseEntity<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.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");
}
}
@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.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,978 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.Collections;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@DisplayName("Watermark Validation Tests")
@ExtendWith(MockitoExtension.class)
class WatermarkValidationTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@InjectMocks private WatermarkController watermarkController;
private AddWatermarkRequest request;
@BeforeEach
void setUp() throws Exception {
request = new AddWatermarkRequest();
MockMultipartFile mockPdfFile =
new MockMultipartFile(
"fileInput", "test.pdf", "application/pdf", "test content".getBytes());
request.setFileInput(mockPdfFile);
request.setWatermarkType("text");
request.setWatermarkText("Test Watermark");
request.setOpacity(0.5f);
request.setConvertPDFToImage(false);
// Mock PDDocument with empty pages to avoid NullPointerException
// Use lenient() because some tests don't reach the document loading code
PDDocument mockDocument = mock(PDDocument.class);
lenient().when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(mockDocument);
// Mock getPages() to return an empty iterable
org.apache.pdfbox.pdmodel.PDPageTree mockPageTree =
mock(org.apache.pdfbox.pdmodel.PDPageTree.class);
lenient().when(mockDocument.getPages()).thenReturn(mockPageTree);
lenient().when(mockPageTree.iterator()).thenReturn(Collections.emptyIterator());
}
@Nested
@DisplayName("Opacity Validation Tests")
class OpacityValidationTests {
@Test
@DisplayName("Should reject opacity below 0.0")
void testOpacityBelowMinimum() {
request.setOpacity(-0.1f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Opacity must be between 0.0 and 1.0"),
"Error message should mention opacity bounds");
}
@Test
@DisplayName("Should reject opacity above 1.0")
void testOpacityAboveMaximum() {
request.setOpacity(1.1f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Opacity must be between 0.0 and 1.0"),
"Error message should mention opacity bounds");
}
}
@Nested
@DisplayName("Rotation Range Validation Tests")
class RotationRangeValidationTests {
@Test
@DisplayName("Should accept valid rotation range")
void testValidRotationRange() {
request.setRotationMin(-45f);
request.setRotationMax(45f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept equal rotation min and max")
void testEqualRotationMinMax() {
request.setRotationMin(30f);
request.setRotationMax(30f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject rotation min greater than max")
void testRotationMinGreaterThanMax() {
request.setRotationMin(45f);
request.setRotationMax(-45f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Rotation minimum")
&& exception.getMessage().contains("must be less than or equal to"),
"Error message should mention rotation range constraint");
}
@Test
@DisplayName("Should accept null rotation values")
void testNullRotationValues() {
request.setRotationMin(null);
request.setRotationMax(null);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept only rotation min set")
void testOnlyRotationMinSet() {
request.setRotationMin(-30f);
request.setRotationMax(null);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept only rotation max set")
void testOnlyRotationMaxSet() {
request.setRotationMin(null);
request.setRotationMax(30f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
}
@Nested
@DisplayName("Font Size Range Validation Tests")
class FontSizeRangeValidationTests {
@Test
@DisplayName("Should accept valid font size range")
void testValidFontSizeRange() {
request.setFontSizeMin(10f);
request.setFontSizeMax(50f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept equal font size min and max")
void testEqualFontSizeMinMax() {
request.setFontSizeMin(30f);
request.setFontSizeMax(30f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject font size min greater than max")
void testFontSizeMinGreaterThanMax() {
request.setFontSizeMin(50f);
request.setFontSizeMax(10f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Font size minimum")
&& exception.getMessage().contains("must be less than or equal to"),
"Error message should mention font size range constraint");
}
@Test
@DisplayName("Should accept null font size values")
void testNullFontSizeValues() {
request.setFontSizeMin(null);
request.setFontSizeMax(null);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
}
@Nested
@DisplayName("Color Format Validation Tests")
class ColorFormatValidationTests {
@Test
@DisplayName("Should accept valid 6-digit hex color")
void testValidHexColor6Digits() {
request.setCustomColor("#FF0000");
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept valid 8-digit hex color with alpha")
void testValidHexColor8Digits() {
request.setCustomColor("#FF0000AA");
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept lowercase hex color")
void testValidHexColorLowercase() {
request.setCustomColor("#ff0000");
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept mixed case hex color")
void testValidHexColorMixedCase() {
request.setCustomColor("#Ff00Aa");
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject hex color without hash")
void testInvalidHexColorNoHash() {
request.setCustomColor("FF0000");
request.setRandomColor(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Invalid color format"),
"Error message should mention invalid color format");
}
@Test
@DisplayName("Should reject hex color with wrong length")
void testInvalidHexColorWrongLength() {
request.setCustomColor("#FFF");
request.setRandomColor(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Invalid color format"),
"Error message should mention invalid color format");
}
@Test
@DisplayName("Should reject hex color with invalid characters")
void testInvalidHexColorInvalidChars() {
request.setCustomColor("#GGGGGG");
request.setRandomColor(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Invalid color format"),
"Error message should mention invalid color format");
}
@Test
@DisplayName("Should skip color validation when using random color")
void testSkipValidationWithRandomColor() {
request.setCustomColor("invalid");
request.setRandomColor(true);
// Should not throw exception because random color is enabled
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should skip color validation when custom color is null")
void testSkipValidationWithNullColor() {
request.setCustomColor(null);
request.setRandomColor(false);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
}
@Nested
@DisplayName("Mirroring Probability Validation Tests")
class MirroringProbabilityValidationTests {
@Test
@DisplayName("Should accept valid mirroring probability values")
void testValidMirroringProbability() {
request.setMirroringProbability(0.0f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
request.setMirroringProbability(0.5f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
request.setMirroringProbability(1.0f);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject mirroring probability below 0.0")
void testMirroringProbabilityBelowMinimum() {
request.setMirroringProbability(-0.1f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception
.getMessage()
.contains("Mirroring probability must be between 0.0 and 1.0"),
"Error message should mention mirroring probability bounds");
}
@Test
@DisplayName("Should reject mirroring probability above 1.0")
void testMirroringProbabilityAboveMaximum() {
request.setMirroringProbability(1.5f);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception
.getMessage()
.contains("Mirroring probability must be between 0.0 and 1.0"),
"Error message should mention mirroring probability bounds");
}
@Test
@DisplayName("Should accept null mirroring probability")
void testNullMirroringProbability() {
request.setMirroringProbability(null);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
}
@Nested
@DisplayName("Watermark Type Validation Tests")
class WatermarkTypeValidationTests {
@Test
@DisplayName("Should accept 'text' watermark type")
void testTextWatermarkType() {
request.setWatermarkType("text");
request.setWatermarkText("Test");
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept 'image' watermark type")
void testImageWatermarkType() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.png", "image/png", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept case-insensitive watermark type")
void testCaseInsensitiveWatermarkType() {
request.setWatermarkType("TEXT");
request.setWatermarkText("Test");
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
request.setWatermarkType("Image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.png", "image/png", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject invalid watermark type")
void testInvalidWatermarkType() {
request.setWatermarkType("invalid");
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark type must be 'text' or 'image'"),
"Error message should mention valid watermark types");
}
@Test
@DisplayName("Should reject null watermark type")
void testNullWatermarkType() {
request.setWatermarkType(null);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark type must be 'text' or 'image'"),
"Error message should mention valid watermark types");
}
@Test
@DisplayName("Should reject text watermark without text")
void testTextWatermarkWithoutText() {
request.setWatermarkType("text");
request.setWatermarkText(null);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark text is required"),
"Error message should mention missing watermark text");
}
@Test
@DisplayName("Should reject text watermark with empty text")
void testTextWatermarkWithEmptyText() {
request.setWatermarkType("text");
request.setWatermarkText(" ");
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark text is required"),
"Error message should mention missing watermark text");
}
@Test
@DisplayName("Should reject image watermark without image")
void testImageWatermarkWithoutImage() {
request.setWatermarkType("image");
request.setWatermarkImage(null);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark image is required"),
"Error message should mention missing watermark image");
}
@Test
@DisplayName("Should reject image watermark with empty image")
void testImageWatermarkWithEmptyImage() {
request.setWatermarkType("image");
MockMultipartFile emptyImage =
new MockMultipartFile("watermarkImage", "", "image/png", new byte[0]);
request.setWatermarkImage(emptyImage);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Watermark image is required"),
"Error message should mention missing watermark image");
}
}
@Nested
@DisplayName("Image Type Validation Tests")
class ImageTypeValidationTests {
@Test
@DisplayName("Should accept PNG image")
void testAcceptPngImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.png", "image/png", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept JPG image")
void testAcceptJpgImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.jpg", "image/jpeg", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept JPEG image")
void testAcceptJpegImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage",
"test.jpeg",
"image/jpeg",
"image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept GIF image")
void testAcceptGifImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.gif", "image/gif", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should accept BMP image")
void testAcceptBmpImage() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.bmp", "image/bmp", "image content".getBytes());
request.setWatermarkImage(imageFile);
assertDoesNotThrow(() -> watermarkController.addWatermark(request));
}
@Test
@DisplayName("Should reject unsupported image content type")
void testRejectUnsupportedImageContentType() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage",
"test.svg",
"image/svg+xml",
"image content".getBytes());
request.setWatermarkImage(imageFile);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Unsupported image type"),
"Error message should mention unsupported image type");
}
@Test
@DisplayName("Should reject unsupported image file extension")
void testRejectUnsupportedImageExtension() {
request.setWatermarkType("image");
MockMultipartFile imageFile =
new MockMultipartFile(
"watermarkImage", "test.svg", "image/png", "image content".getBytes());
request.setWatermarkImage(imageFile);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> watermarkController.addWatermark(request));
assertTrue(
exception.getMessage().contains("Unsupported image file extension"),
"Error message should mention unsupported file extension");
}
}
@Nested
@DisplayName("Annotation-based Validation Tests")
class AnnotationBasedValidationTests {
private Validator validator;
@BeforeEach
void setUpValidator() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
@Test
@DisplayName("Should reject count below minimum of 1")
void testCountMinimum() {
// Arrange
request.setCount(0);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("count")),
"Should have violation on 'count' field");
}
@Test
@DisplayName("Should accept valid count value")
void testCountValid() {
// Arrange
request.setCount(5);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertTrue(
violations.stream()
.noneMatch(v -> v.getPropertyPath().toString().equals("count")),
"Should have no violations on 'count' field");
}
@Test
@DisplayName("Should reject count above maximum of 1000")
void testCountMaximum() {
// Arrange
request.setCount(1001);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("count")),
"Should have violation on 'count' field");
}
@Test
@DisplayName("Should reject rotationMin below -360")
void testRotationMinBelowBound() {
// Arrange
request.setRotationMin(-361f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("rotationMin")),
"Should have violation on 'rotationMin' field");
}
@Test
@DisplayName("Should reject rotationMax above 360")
void testRotationMaxAboveBound() {
// Arrange
request.setRotationMax(361f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("rotationMax")),
"Should have violation on 'rotationMax' field");
}
@Test
@DisplayName("Should accept valid rotation values")
void testRotationValid() {
// Arrange
request.setRotationMin(-180f);
request.setRotationMax(180f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertTrue(
violations.stream()
.noneMatch(
v ->
v.getPropertyPath().toString().equals("rotationMin")
|| v.getPropertyPath()
.toString()
.equals("rotationMax")),
"Should have no violations on rotation fields");
}
@Test
@DisplayName("Should reject fontSizeMin below 1.0")
void testFontSizeMinBelowBound() {
// Arrange
request.setFontSizeMin(0.5f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("fontSizeMin")),
"Should have violation on 'fontSizeMin' field");
}
@Test
@DisplayName("Should reject fontSizeMax above 500.0")
void testFontSizeMaxAboveBound() {
// Arrange
request.setFontSizeMax(501f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("fontSizeMax")),
"Should have violation on 'fontSizeMax' field");
}
@Test
@DisplayName("Should accept valid font size values")
void testFontSizeValid() {
// Arrange
request.setFontSizeMin(10f);
request.setFontSizeMax(100f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertTrue(
violations.stream()
.noneMatch(
v ->
v.getPropertyPath().toString().equals("fontSizeMin")
|| v.getPropertyPath()
.toString()
.equals("fontSizeMax")),
"Should have no violations on font size fields");
}
@Test
@DisplayName("Should reject perLetterFontCount below 1")
void testPerLetterFontCountMinimum() {
// Arrange
request.setPerLetterFontCount(0);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(
v ->
v.getPropertyPath()
.toString()
.equals("perLetterFontCount")),
"Should have violation on 'perLetterFontCount' field");
}
@Test
@DisplayName("Should reject perLetterFontCount above 20")
void testPerLetterFontCountMaximum() {
// Arrange
request.setPerLetterFontCount(21);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(
v ->
v.getPropertyPath()
.toString()
.equals("perLetterFontCount")),
"Should have violation on 'perLetterFontCount' field");
}
@Test
@DisplayName("Should accept valid perLetterFontCount value")
void testPerLetterFontCountValid() {
// Arrange
request.setPerLetterFontCount(5);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertTrue(
violations.stream()
.noneMatch(
v ->
v.getPropertyPath()
.toString()
.equals("perLetterFontCount")),
"Should have no violations on 'perLetterFontCount' field");
}
@Test
@DisplayName("Should reject perLetterColorCount below 1")
void testPerLetterColorCountMinimum() {
// Arrange
request.setPerLetterColorCount(0);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(
v ->
v.getPropertyPath()
.toString()
.equals("perLetterColorCount")),
"Should have violation on 'perLetterColorCount' field");
}
@Test
@DisplayName("Should reject perLetterColorCount above 20")
void testPerLetterColorCountMaximum() {
// Arrange
request.setPerLetterColorCount(21);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(
v ->
v.getPropertyPath()
.toString()
.equals("perLetterColorCount")),
"Should have violation on 'perLetterColorCount' field");
}
@Test
@DisplayName("Should accept valid perLetterColorCount value")
void testPerLetterColorCountValid() {
// Arrange
request.setPerLetterColorCount(4);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertTrue(
violations.stream()
.noneMatch(
v ->
v.getPropertyPath()
.toString()
.equals("perLetterColorCount")),
"Should have no violations on 'perLetterColorCount' field");
}
@Test
@DisplayName("Should reject imageScale below 0.1")
void testImageScaleBelowBound() {
// Arrange
request.setImageScale(0.05f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("imageScale")),
"Should have violation on 'imageScale' field");
}
@Test
@DisplayName("Should reject imageScale above 10.0")
void testImageScaleAboveBound() {
// Arrange
request.setImageScale(11f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertFalse(violations.isEmpty(), "Should have validation errors");
assertTrue(
violations.stream()
.anyMatch(v -> v.getPropertyPath().toString().equals("imageScale")),
"Should have violation on 'imageScale' field");
}
@Test
@DisplayName("Should accept valid imageScale value")
void testImageScaleValid() {
// Arrange
request.setImageScale(1.5f);
// Act
Set<ConstraintViolation<AddWatermarkRequest>> violations = validator.validate(request);
// Assert
assertTrue(
violations.stream()
.noneMatch(v -> v.getPropertyPath().toString().equals("imageScale")),
"Should have no violations on 'imageScale' field");
}
}
}