diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java
index aa453bc74..9a8021bdc 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java
@@ -7,11 +7,13 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
-import java.time.LocalDate;
-import java.time.LocalTime;
+import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
+import java.util.UUID;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import javax.imageio.ImageIO;
@@ -58,6 +60,13 @@ public class StampController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
+ private static final int MAX_DATE_FORMAT_LENGTH = 50;
+ private static final Pattern SAFE_DATE_FORMAT_PATTERN =
+ Pattern.compile("^[yMdHhmsS/\\-:\\s.,'+EGuwWDFzZXa]+$");
+ private static final Pattern CUSTOM_DATE_PATTERN = Pattern.compile("@date\\{([^}]{1,50})\\}");
+ // Placeholder for escaped @ symbol (using Unicode private use area)
+ private static final String ESCAPED_AT_PLACEHOLDER = "\uE000ESCAPED_AT\uE000";
+
/**
* Initialize data binder for multipart file uploads. This method registers a custom editor for
* MultipartFile to handle file uploads. It sets the MultipartFile to null if the uploaded file
@@ -166,7 +175,9 @@ public class StampController {
overrideX,
overrideY,
margin,
- customColor);
+ customColor,
+ pageIndex,
+ pdfFileName);
} else if ("image".equalsIgnoreCase(stampType)) {
addImageStamp(
contentStream,
@@ -201,9 +212,11 @@ public class StampController {
float fontSize,
String alphabet,
float overrideX, // X override
- float overrideY,
+ float overrideY, // Y override
float margin,
- String colorString) // Y override
+ String colorString,
+ int currentPageNumber,
+ String filename)
throws IOException {
String resourceDir;
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
@@ -231,8 +244,6 @@ public class StampController {
}
}
- contentStream.setFont(font, fontSize);
-
Color redactColor;
try {
if (!colorString.startsWith("#")) {
@@ -240,47 +251,54 @@ public class StampController {
}
redactColor = Color.decode(colorString);
} catch (NumberFormatException e) {
-
redactColor = Color.LIGHT_GRAY;
}
contentStream.setNonStrokingColor(redactColor);
- PDRectangle pageSize = page.getMediaBox();
- float x, y;
-
- if (overrideX >= 0 && overrideY >= 0) {
- // Use override values if provided
- x = overrideX;
- y = overrideY;
- } else {
- x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
- y =
- calculatePositionY(
- pageSize, position, calculateTextCapHeight(font, fontSize), margin);
- }
-
- String currentDate = LocalDate.now().toString();
- String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
-
int pageCount = document.getNumberOfPages();
String processedStampText =
- stampText
- .replace("@date", currentDate)
- .replace("@time", currentTime)
- .replace("@page_count", String.valueOf(pageCount));
+ processStampText(stampText, currentPageNumber, pageCount, filename, document);
- // Split the stampText into multiple lines
- String[] lines =
+ String normalizedText =
RegexPatternUtils.getInstance()
.getEscapedNewlinePattern()
- .split(processedStampText);
+ .matcher(processedStampText)
+ .replaceAll("\n");
+ String[] lines = normalizedText.split("\\r?\\n");
+
+ PDRectangle pageSize = page.getMediaBox();
+
+ // Use fontSize directly (default 40 if not specified)
+ float effectiveFontSize = fontSize > 0 ? fontSize : 40f;
+
+ contentStream.setFont(font, effectiveFontSize);
// Calculate dynamic line height based on font ascent and descent
float ascent = font.getFontDescriptor().getAscent();
float descent = font.getFontDescriptor().getDescent();
- float lineHeight = ((ascent - descent) / 1000) * fontSize;
+ float lineHeight = ((ascent - descent) / 1000) * effectiveFontSize;
+
+ float maxLineWidth = 0;
+ for (String line : lines) {
+ float lineWidth = calculateTextWidth(line, font, effectiveFontSize);
+ if (lineWidth > maxLineWidth) {
+ maxLineWidth = lineWidth;
+ }
+ }
+
+ float totalTextHeight = lines.length * lineHeight;
+
+ float x, y;
+
+ if (overrideX >= 0 && overrideY >= 0) {
+ x = overrideX;
+ y = overrideY;
+ } else {
+ x = calculatePositionX(pageSize, position, maxLineWidth, margin);
+ y = calculatePositionY(pageSize, position, totalTextHeight, margin);
+ }
contentStream.beginText();
for (int i = 0; i < lines.length; i++) {
@@ -293,6 +311,140 @@ public class StampController {
contentStream.endText();
}
+ /**
+ * Process stamp text by replacing all @commands with their actual values. Supported commands:
+ *
+ *
Date & Time:
+ *
+ *
+ * - @date - Current date (YYYY-MM-DD)
+ *
- @time - Current time (HH:mm:ss)
+ *
- @datetime - Current date and time (YYYY-MM-DD HH:mm:ss)
+ *
- @date{format} - Custom date/time format (e.g., @date{dd/MM/yyyy})
+ *
- @year - Current year (4 digits)
+ *
- @month - Current month (01-12)
+ *
- @day - Current day of month (01-31)
+ *
+ *
+ * Page Information:
+ *
+ *
+ * - @page_number or @page - Current page number
+ *
- @total_pages or @page_count - Total number of pages
+ *
+ *
+ * File Information:
+ *
+ *
+ * - @filename - Original filename (without extension)
+ *
- @filename_full - Original filename (with extension)
+ *
+ *
+ * Document Metadata:
+ *
+ *
+ * - @author - Document author (from PDF metadata)
+ *
- @title - Document title (from PDF metadata)
+ *
- @subject - Document subject (from PDF metadata)
+ *
+ *
+ * Other:
+ *
+ *
+ * - @uuid - Short unique identifier (8 characters)
+ *
+ */
+ private String processStampText(
+ String stampText,
+ int currentPageNumber,
+ int totalPages,
+ String filename,
+ PDDocument document) {
+ if (stampText == null || stampText.isEmpty()) {
+ return "";
+ }
+
+ // Handle escaped @@ sequences first - replace with placeholder to preserve literal @
+ String result = stampText.replace("@@", ESCAPED_AT_PLACEHOLDER);
+
+ LocalDateTime now = LocalDateTime.now();
+ String currentDate = now.toLocalDate().toString();
+ String currentTime = now.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
+ String currentDateTime = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+
+ String filenameWithoutExt = filename != null ? filename : "";
+ if (filename != null && filename.contains(".")) {
+ int lastDot = filename.lastIndexOf('.');
+ if (lastDot > 0) { // Ensure there's actually a name before the dot
+ filenameWithoutExt = filename.substring(0, lastDot);
+ }
+ }
+
+ String author = "";
+ String title = "";
+ String subject = "";
+ if (document != null && document.getDocumentInformation() != null) {
+ var info = document.getDocumentInformation();
+ author = info.getAuthor() != null ? info.getAuthor() : "";
+ title = info.getTitle() != null ? info.getTitle() : "";
+ subject = info.getSubject() != null ? info.getSubject() : "";
+ }
+
+ String uuid = UUID.randomUUID().toString().substring(0, 8);
+
+ // Process @date{format} with custom format first (must be before simple @date)
+ Matcher matcher = CUSTOM_DATE_PATTERN.matcher(result);
+ StringBuilder sb = new StringBuilder();
+ while (matcher.find()) {
+ String format = matcher.group(1);
+ String replacement = processCustomDateFormat(format, now);
+ matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
+ }
+ matcher.appendTail(sb);
+ result = sb.toString();
+
+ result =
+ result.replace("@datetime", currentDateTime)
+ .replace("@date", currentDate)
+ .replace("@time", currentTime)
+ .replace("@year", String.valueOf(now.getYear()))
+ .replace("@month", String.format("%02d", now.getMonthValue()))
+ .replace("@day", String.format("%02d", now.getDayOfMonth()))
+ .replace("@page_number", String.valueOf(currentPageNumber))
+ .replace(
+ "@page_count", String.valueOf(totalPages)) // Must come before @page
+ .replace("@total_pages", String.valueOf(totalPages))
+ .replace(
+ "@page",
+ String.valueOf(currentPageNumber)) // Must come after @page_count
+ .replace("@filename_full", filename != null ? filename : "")
+ .replace("@filename", filenameWithoutExt)
+ .replace("@author", author)
+ .replace("@title", title)
+ .replace("@subject", subject)
+ .replace("@uuid", uuid);
+
+ result = result.replace(ESCAPED_AT_PLACEHOLDER, "@");
+
+ return result;
+ }
+
+ private String processCustomDateFormat(String format, LocalDateTime now) {
+ if (format == null || format.length() > MAX_DATE_FORMAT_LENGTH) {
+ return "[invalid format: too long]";
+ }
+
+ if (!SAFE_DATE_FORMAT_PATTERN.matcher(format).matches()) {
+ return "[invalid format]";
+ }
+
+ try {
+ return now.format(DateTimeFormatter.ofPattern(format));
+ } catch (IllegalArgumentException e) {
+ return "[invalid format: " + format + "]";
+ }
+ }
+
private void addImageStamp(
PDPageContentStream contentStream,
MultipartFile stampImage,
@@ -329,8 +481,8 @@ public class StampController {
x = overrideX;
y = overrideY;
} else {
- x = calculatePositionX(pageSize, position, desiredPhysicalWidth, null, 0, null, margin);
- y = calculatePositionY(pageSize, position, fontSize, margin);
+ x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin);
+ y = calculatePositionY(pageSize, position, desiredPhysicalHeight, margin);
}
contentStream.saveGraphicsState();
@@ -341,23 +493,14 @@ public class StampController {
}
private float calculatePositionX(
- PDRectangle pageSize,
- int position,
- float contentWidth,
- PDFont font,
- float fontSize,
- String text,
- float margin)
- throws IOException {
- float actualWidth =
- (text != null) ? calculateTextWidth(text, font, fontSize) : contentWidth;
+ PDRectangle pageSize, int position, float contentWidth, float margin) {
return switch (position % 3) {
case 1: // Left
yield pageSize.getLowerLeftX() + margin;
case 2: // Center
- yield (pageSize.getWidth() - actualWidth) / 2;
+ yield (pageSize.getWidth() - contentWidth) / 2;
case 0: // Right
- yield pageSize.getUpperRightX() - actualWidth - margin;
+ yield pageSize.getUpperRightX() - contentWidth - margin;
default:
yield 0;
};
@@ -366,12 +509,12 @@ public class StampController {
private float calculatePositionY(
PDRectangle pageSize, int position, float height, float margin) {
return switch ((position - 1) / 3) {
- case 0: // Top
- yield pageSize.getUpperRightY() - height - margin;
- case 1: // Middle
- yield (pageSize.getHeight() - height) / 2;
- case 2: // Bottom
- yield pageSize.getLowerLeftY() + margin;
+ case 0: // Top - first line near the top
+ yield pageSize.getUpperRightY() - margin;
+ case 1: // Middle - center of text block at page center
+ yield (pageSize.getHeight() + height) / 2;
+ case 2: // Bottom - first line positioned so last line is at bottom margin
+ yield pageSize.getLowerLeftY() + margin + height;
default:
yield 0;
};
@@ -380,8 +523,4 @@ public class StampController {
private float calculateTextWidth(String text, PDFont font, float fontSize) throws IOException {
return font.getStringWidth(text) / 1000 * fontSize;
}
-
- private float calculateTextCapHeight(PDFont font, float fontSize) {
- return font.getFontDescriptor().getCapHeight() / 1000 * fontSize;
- }
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddStampRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddStampRequest.java
index 98dbcdbcc..25a5f9e64 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddStampRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddStampRequest.java
@@ -32,8 +32,8 @@ public class AddStampRequest extends PDFWithPageNums {
private String alphabet = "roman";
@Schema(
- description = "The font size of the stamp text and image",
- defaultValue = "30",
+ description = "The font size of the stamp text and image in points.",
+ defaultValue = "40",
requiredMode = Schema.RequiredMode.REQUIRED)
private float fontSize;
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/StampControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/StampControllerTest.java
new file mode 100644
index 000000000..3d6359538
--- /dev/null
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/StampControllerTest.java
@@ -0,0 +1,567 @@
+package stirling.software.SPDF.controller.api.misc;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.time.LocalDateTime;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDDocumentInformation;
+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.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import stirling.software.common.service.CustomPDFDocumentFactory;
+import stirling.software.common.util.TempFileManager;
+
+@ExtendWith(MockitoExtension.class)
+class StampControllerTest {
+
+ @Mock private CustomPDFDocumentFactory pdfDocumentFactory;
+ @Mock private TempFileManager tempFileManager;
+
+ @InjectMocks private StampController stampController;
+
+ private Method processStampTextMethod;
+ private Method processCustomDateFormatMethod;
+
+ @BeforeEach
+ void setUp() throws NoSuchMethodException {
+ processStampTextMethod =
+ StampController.class.getDeclaredMethod(
+ "processStampText",
+ String.class,
+ int.class,
+ int.class,
+ String.class,
+ PDDocument.class);
+ processStampTextMethod.setAccessible(true);
+
+ processCustomDateFormatMethod =
+ StampController.class.getDeclaredMethod(
+ "processCustomDateFormat", String.class, LocalDateTime.class);
+ processCustomDateFormatMethod.setAccessible(true);
+ }
+
+ private String invokeProcessStampText(
+ String stampText, int pageNumber, int totalPages, String filename, PDDocument document)
+ throws Exception {
+ try {
+ return (String)
+ processStampTextMethod.invoke(
+ stampController, stampText, pageNumber, totalPages, filename, document);
+ } catch (InvocationTargetException e) {
+ throw (Exception) e.getCause();
+ }
+ }
+
+ private String invokeProcessCustomDateFormat(String format, LocalDateTime now)
+ throws Exception {
+ try {
+ return (String) processCustomDateFormatMethod.invoke(stampController, format, now);
+ } catch (InvocationTargetException e) {
+ throw (Exception) e.getCause();
+ }
+ }
+
+ @Nested
+ @DisplayName("Basic Variable Substitution Tests")
+ class BasicVariableTests {
+
+ @Test
+ @DisplayName("Should replace @page_number with current page")
+ void testPageNumberReplacement() throws Exception {
+ String result = invokeProcessStampText("Page @page_number", 5, 20, "test.pdf", null);
+ assertEquals("Page 5", result);
+ }
+
+ @Test
+ @DisplayName("Should replace @total_pages with total page count")
+ void testTotalPagesReplacement() throws Exception {
+ String result =
+ invokeProcessStampText("of @total_pages pages", 1, 100, "test.pdf", null);
+ assertEquals("of 100 pages", result);
+ }
+
+ @Test
+ @DisplayName("Should replace combined page variables")
+ void testCombinedPageVariables() throws Exception {
+ String result =
+ invokeProcessStampText(
+ "Page @page_number of @total_pages", 5, 20, "test.pdf", null);
+ assertEquals("Page 5 of 20", result);
+ }
+
+ @Test
+ @DisplayName("Should replace @page alias")
+ void testPageAlias() throws Exception {
+ String result = invokeProcessStampText("Page @page", 7, 10, "test.pdf", null);
+ assertEquals("Page 7", result);
+ }
+
+ @Test
+ @DisplayName("Should replace @page_count alias for total pages")
+ void testPageCountAlias() throws Exception {
+ String result = invokeProcessStampText("Total: @page_count", 1, 50, "test.pdf", null);
+ assertEquals("Total: 50", result);
+ }
+ }
+
+ @Nested
+ @DisplayName("Filename Variable Tests")
+ class FilenameTests {
+
+ @Test
+ @DisplayName("Should replace @filename with filename without extension")
+ void testFilenameWithoutExtension() throws Exception {
+ String result = invokeProcessStampText("File: @filename", 1, 1, "document.pdf", null);
+ assertEquals("File: document", result);
+ }
+
+ @Test
+ @DisplayName("Should replace @filename_full with full filename")
+ void testFilenameWithExtension() throws Exception {
+ String result =
+ invokeProcessStampText("File: @filename_full", 1, 1, "document.pdf", null);
+ assertEquals("File: document.pdf", result);
+ }
+
+ @Test
+ @DisplayName("Should handle filename without extension")
+ void testFilenameWithoutDot() throws Exception {
+ String result = invokeProcessStampText("@filename", 1, 1, "document", null);
+ assertEquals("document", result);
+ }
+
+ @Test
+ @DisplayName("Should handle null filename")
+ void testNullFilename() throws Exception {
+ String result = invokeProcessStampText("File: @filename", 1, 1, null, null);
+ assertEquals("File: ", result);
+ }
+
+ @Test
+ @DisplayName("Should handle filename with multiple dots")
+ void testFilenameMultipleDots() throws Exception {
+ String result = invokeProcessStampText("@filename", 1, 1, "my.document.v2.pdf", null);
+ assertEquals("my.document.v2", result);
+ }
+
+ @Test
+ @DisplayName("Should handle hidden file (starts with dot)")
+ void testHiddenFile() throws Exception {
+ String result = invokeProcessStampText("@filename", 1, 1, ".hidden.pdf", null);
+ assertEquals(".hidden", result);
+ }
+ }
+
+ @Nested
+ @DisplayName("Date/Time Variable Tests")
+ class DateTimeTests {
+
+ @Test
+ @DisplayName("Should replace @date with current date")
+ void testDateReplacement() throws Exception {
+ String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
+ assertTrue(
+ result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
+ "Date should match YYYY-MM-DD format");
+ }
+
+ @Test
+ @DisplayName("Should replace @time with current time")
+ void testTimeReplacement() throws Exception {
+ String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
+ assertTrue(
+ result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
+ "Time should match HH:mm:ss format");
+ }
+
+ @Test
+ @DisplayName("Should replace @datetime with combined date and time")
+ void testDateTimeReplacement() throws Exception {
+ String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
+ // DateTime format: YYYY-MM-DD HH:mm:ss
+ assertTrue(
+ result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
+ "DateTime should match YYYY-MM-DD HH:mm:ss format");
+ }
+
+ @Test
+ @DisplayName("Should replace @year with current year")
+ void testYearReplacement() throws Exception {
+ String result = invokeProcessStampText("© @year", 1, 1, "test.pdf", null);
+ int currentYear = LocalDateTime.now().getYear();
+ assertEquals("© " + currentYear, result);
+ }
+
+ @Test
+ @DisplayName("Should replace @month with zero-padded month")
+ void testMonthReplacement() throws Exception {
+ String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
+ assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
+ }
+
+ @Test
+ @DisplayName("Should replace @day with zero-padded day")
+ void testDayReplacement() throws Exception {
+ String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
+ assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
+ }
+ }
+
+ @Nested
+ @DisplayName("Custom Date Format Tests")
+ class CustomDateFormatTests {
+
+ @Test
+ @DisplayName("Should handle custom date format dd/MM/yyyy")
+ void testCustomDateFormatSlash() throws Exception {
+ String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
+ assertTrue(
+ result.matches("\\d{2}/\\d{2}/\\d{4}"),
+ "Should match dd/MM/yyyy format: " + result);
+ }
+
+ @Test
+ @DisplayName("Should handle custom date format with time")
+ void testCustomDateFormatWithTime() throws Exception {
+ String result =
+ invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
+ assertTrue(
+ result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
+ "Should match yyyy-MM-dd HH:mm format: " + result);
+ }
+
+ @Test
+ @DisplayName("Should handle multiple custom date formats in same text")
+ void testMultipleCustomDateFormats() throws Exception {
+ String result =
+ invokeProcessStampText(
+ "Start: @date{dd/MM/yyyy} End: @date{yyyy}", 1, 1, "test.pdf", null);
+ assertTrue(result.contains("/"), "Should contain slash from first format");
+ // Should have year twice (once with slashes, once alone)
+ }
+ }
+
+ @Nested
+ @DisplayName("Custom Date Format Security Tests")
+ class CustomDateFormatSecurityTests {
+
+ @Test
+ @DisplayName("Should not match format that is too long - regex won't capture it")
+ void testFormatTooLong() throws Exception {
+ String longFormat = "y".repeat(51); // 51 chars, over the 50 char regex limit
+ String result =
+ invokeProcessStampText("@date{" + longFormat + "}", 1, 1, "test.pdf", null);
+ // The CUSTOM_DATE_PATTERN only captures up to 50 chars, so this won't match
+ // The @date part will be replaced by simple replacement, leaving {yyy...}
+ assertTrue(
+ result.contains("{"), "Should contain { because regex didn't match: " + result);
+ }
+
+ @Test
+ @DisplayName("Should reject format with unsafe characters - shell injection attempt")
+ void testShellInjectionAttempt() throws Exception {
+ String result =
+ invokeProcessStampText("@date{yyyy-MM-dd$(rm -rf /)}", 1, 1, "test.pdf", null);
+ assertEquals("[invalid format]", result);
+ }
+
+ @Test
+ @DisplayName("Should reject format with unsafe characters - semicolon")
+ void testSemicolonInjection() throws Exception {
+ String result = invokeProcessStampText("@date{yyyy;rm}", 1, 1, "test.pdf", null);
+ assertEquals("[invalid format]", result);
+ }
+
+ @Test
+ @DisplayName("Should reject format with unsafe characters - backticks")
+ void testBacktickInjection() throws Exception {
+ String result = invokeProcessStampText("@date{`whoami`}", 1, 1, "test.pdf", null);
+ assertEquals("[invalid format]", result);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"$(cmd)", "`cmd`", ";cmd", "|cmd", "&cmd", "cmd"})
+ @DisplayName("Should reject various injection attempts")
+ void testVariousInjectionAttempts(String injection) throws Exception {
+ String result =
+ invokeProcessStampText("@date{yyyy" + injection + "}", 1, 1, "test.pdf", null);
+ assertEquals("[invalid format]", result);
+ }
+
+ @Test
+ @DisplayName("Should accept valid format characters")
+ void testValidFormatCharacters() throws Exception {
+ // All these should be valid based on SAFE_DATE_FORMAT_PATTERN: yMdHhmsS/-:.,
+ // '+EGuwWDFzZXa and space
+ String result =
+ invokeProcessStampText("@date{yyyy-MM-dd HH:mm:ss}", 1, 1, "test.pdf", null);
+ assertFalse(
+ result.startsWith("[invalid"), "Valid format should be accepted: " + result);
+ }
+
+ @Test
+ @DisplayName("Should handle invalid DateTimeFormatter pattern gracefully")
+ void testInvalidFormatterPattern() throws Exception {
+ LocalDateTime now = LocalDateTime.now();
+ // Use 'sssss' - too many seconds digits will throw IllegalArgumentException from
+ // DateTimeFormatter
+ // Note: The pattern 'sssss' passes the SAFE_DATE_FORMAT_PATTERN but fails
+ // DateTimeFormatter.ofPattern()
+ String result = invokeProcessCustomDateFormat("sssss", now);
+ assertTrue(
+ result.startsWith("[invalid format:"),
+ "Invalid pattern should return error message: " + result);
+ }
+ }
+
+ @Nested
+ @DisplayName("Escape Sequence Tests")
+ class EscapeSequenceTests {
+
+ @Test
+ @DisplayName("Should convert @@ to literal @")
+ void testDoubleAtEscape() throws Exception {
+ String result =
+ invokeProcessStampText("Email: test@@example.com", 1, 1, "test.pdf", null);
+ assertEquals("Email: test@example.com", result);
+ }
+
+ @Test
+ @DisplayName("Should preserve @@ before variable")
+ void testEscapeBeforeVariable() throws Exception {
+ String result = invokeProcessStampText("@@date is @date", 1, 1, "test.pdf", null);
+ // @@date should become @date, and @date should be replaced with actual date
+ assertTrue(result.startsWith("@date is "), "Should start with literal @date");
+ assertTrue(
+ result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
+ "Should have date after: " + result);
+ }
+
+ @Test
+ @DisplayName("Should handle multiple escape sequences")
+ void testMultipleEscapes() throws Exception {
+ String result = invokeProcessStampText("@@one @@two @@three", 1, 1, "test.pdf", null);
+ assertEquals("@one @two @three", result);
+ }
+
+ @Test
+ @DisplayName("Should handle escape at end of string")
+ void testEscapeAtEnd() throws Exception {
+ String result = invokeProcessStampText("Contact: user@@", 1, 1, "test.pdf", null);
+ assertEquals("Contact: user@", result);
+ }
+ }
+
+ @Nested
+ @DisplayName("Document Metadata Tests")
+ class DocumentMetadataTests {
+
+ @Test
+ @DisplayName("Should replace @author with document author")
+ void testAuthorReplacement() throws Exception {
+ PDDocument doc = new PDDocument();
+ PDDocumentInformation info = new PDDocumentInformation();
+ info.setAuthor("John Doe");
+ doc.setDocumentInformation(info);
+
+ try {
+ String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", doc);
+ assertEquals("Author: John Doe", result);
+ } finally {
+ doc.close();
+ }
+ }
+
+ @Test
+ @DisplayName("Should replace @title with document title")
+ void testTitleReplacement() throws Exception {
+ PDDocument doc = new PDDocument();
+ PDDocumentInformation info = new PDDocumentInformation();
+ info.setTitle("My Document Title");
+ doc.setDocumentInformation(info);
+
+ try {
+ String result = invokeProcessStampText("Title: @title", 1, 1, "test.pdf", doc);
+ assertEquals("Title: My Document Title", result);
+ } finally {
+ doc.close();
+ }
+ }
+
+ @Test
+ @DisplayName("Should replace @subject with document subject")
+ void testSubjectReplacement() throws Exception {
+ PDDocument doc = new PDDocument();
+ PDDocumentInformation info = new PDDocumentInformation();
+ info.setSubject("Important Subject");
+ doc.setDocumentInformation(info);
+
+ try {
+ String result = invokeProcessStampText("Subject: @subject", 1, 1, "test.pdf", doc);
+ assertEquals("Subject: Important Subject", result);
+ } finally {
+ doc.close();
+ }
+ }
+
+ @Test
+ @DisplayName("Should handle null metadata gracefully")
+ void testNullMetadata() throws Exception {
+ PDDocument doc = new PDDocument();
+ // Don't set any document information
+
+ try {
+ String result =
+ invokeProcessStampText("@author @title @subject", 1, 1, "test.pdf", doc);
+ assertEquals(" ", result); // All should be empty strings
+ } finally {
+ doc.close();
+ }
+ }
+
+ @Test
+ @DisplayName("Should handle null document gracefully")
+ void testNullDocument() throws Exception {
+ String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", null);
+ assertEquals("Author: ", result);
+ }
+ }
+
+ @Nested
+ @DisplayName("UUID Variable Tests")
+ class UuidTests {
+
+ @Test
+ @DisplayName("Should generate 8-character UUID")
+ void testUuidLength() throws Exception {
+ String result = invokeProcessStampText("ID: @uuid", 1, 1, "test.pdf", null);
+ // UUID format: "ID: " + 8 chars
+ assertEquals(12, result.length(), "Should be 'ID: ' + 8 char UUID");
+ }
+
+ @Test
+ @DisplayName("Should generate different UUIDs for each call")
+ void testUuidUniqueness() throws Exception {
+ String result1 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
+ String result2 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
+ assertNotEquals(result1, result2, "UUIDs should be unique");
+ }
+
+ @Test
+ @DisplayName("UUID should contain only hex characters")
+ void testUuidFormat() throws Exception {
+ String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
+ assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
+ }
+ }
+
+ @Nested
+ @DisplayName("Edge Cases and Error Handling")
+ class EdgeCaseTests {
+
+ @Test
+ @DisplayName("Should handle null stamp text")
+ void testNullStampText() throws Exception {
+ String result = invokeProcessStampText(null, 1, 1, "test.pdf", null);
+ assertEquals("", result);
+ }
+
+ @Test
+ @DisplayName("Should handle empty stamp text")
+ void testEmptyStampText() throws Exception {
+ String result = invokeProcessStampText("", 1, 1, "test.pdf", null);
+ assertEquals("", result);
+ }
+
+ @Test
+ @DisplayName("Should handle text with no variables")
+ void testNoVariables() throws Exception {
+ String result = invokeProcessStampText("Just plain text", 1, 1, "test.pdf", null);
+ assertEquals("Just plain text", result);
+ }
+
+ @Test
+ @DisplayName("Should handle unknown variables")
+ void testUnknownVariable() throws Exception {
+ String result = invokeProcessStampText("@unknown_var", 1, 1, "test.pdf", null);
+ assertEquals("@unknown_var", result);
+ }
+
+ @Test
+ @DisplayName("Should preserve text around variables")
+ void testPreservesSurroundingText() throws Exception {
+ String result =
+ invokeProcessStampText("Before @page_number After", 5, 10, "test.pdf", null);
+ assertEquals("Before 5 After", result);
+ }
+
+ @Test
+ @DisplayName("Should handle multiple same variables")
+ void testMultipleSameVariables() throws Exception {
+ String result =
+ invokeProcessStampText("@page_number / @page_number", 3, 10, "test.pdf", null);
+ assertEquals("3 / 3", result);
+ }
+
+ @Test
+ @DisplayName("Should handle variables adjacent to each other")
+ void testAdjacentVariables() throws Exception {
+ String result = invokeProcessStampText("@page@page_number", 5, 10, "test.pdf", null);
+ // @page should be replaced first (it's in the order), then @page_number
+ // Since @page_number is longer and comes first in replace chain, should work
+ assertEquals("55", result);
+ }
+ }
+
+ @Nested
+ @DisplayName("Complex Scenario Tests")
+ class ComplexScenarioTests {
+
+ @Test
+ @DisplayName("Should handle legal footer template")
+ void testLegalFooterTemplate() throws Exception {
+ String template = "© @year - All Rights Reserved\\n@filename - Page @page_number";
+ String result = invokeProcessStampText(template, 3, 15, "contract.pdf", null);
+
+ int year = LocalDateTime.now().getYear();
+ String expected = "© " + year + " - All Rights Reserved\\ncontract - Page 3";
+ assertEquals(expected, result);
+ }
+
+ @Test
+ @DisplayName("Should handle Brazilian date format template")
+ void testBrazilianDateFormat() throws Exception {
+ String template = "Documento criado em @date{dd/MM/yyyy} às @time";
+ String result = invokeProcessStampText(template, 1, 1, "doc.pdf", null);
+
+ assertTrue(result.startsWith("Documento criado em "));
+ assertTrue(result.contains("/"));
+ assertTrue(result.contains(":"));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "'Page @page_number of @total_pages', 1, 10, 'Page 1 of 10'",
+ "'Page @page_number of @total_pages', 5, 20, 'Page 5 of 20'",
+ "'Page @page_number of @total_pages', 100, 1000, 'Page 100 of 1000'"
+ })
+ @DisplayName("Should handle page number template with various values")
+ void testPageNumberTemplates(String template, int page, int total, String expected)
+ throws Exception {
+ String result = invokeProcessStampText(template, page, total, "test.pdf", null);
+ assertEquals(expected, result);
+ }
+ }
+}
diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index e8d87f29f..ce65c64b5 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -3560,6 +3560,39 @@ imageSize = "Image Size"
margin = "Margin"
positionAndFormatting = "Position & Formatting"
quickPosition = "Select a position on the page to place the stamp."
+preview = "Preview:"
+useTemplate = "Use Template"
+selectTemplate = "Select a template..."
+stampTextDescription = "Use dynamic variables below. Use @@ for literal @. Use \\n for new lines."
+dynamicVariables = "Dynamic Variables"
+clickToExpand = "Click to expand"
+variablesHelp = "Click on any variable to insert it into your stamp text. Use @@ for literal @."
+dateTimeVars = "Date & Time"
+dateDesc = "Current date"
+timeDesc = "Current time"
+datetimeDesc = "Date and time combined"
+customDateDesc = "Custom format"
+yearMonthDayDesc = "Individual date parts"
+pageVars = "Page Information"
+pageNumberDesc = "Current page number"
+totalPagesDesc = "Total number of pages"
+fileVars = "File Information"
+filenameDesc = "Filename without extension"
+filenameFullDesc = "Filename with extension"
+metadataVars = "Document Metadata"
+metadataDesc = "From PDF document properties"
+otherVars = "Other"
+uuidDesc = "Short unique identifier (8 chars)"
+examples = "Examples"
+multiLine = "multi-line"
+
+[AddStampRequest.template]
+pageNumberFooter = "Page Number Footer"
+dateHeader = "Date Header"
+europeanDate = "European Date"
+timestamp = "Timestamp"
+draftWatermark = "Draft Watermark"
+custom = "Custom"
[AddStampRequest.error]
failed = "An error occurred while adding stamp to the PDF."
diff --git a/frontend/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx b/frontend/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx
index e7f67494d..9381954f2 100644
--- a/frontend/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx
+++ b/frontend/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx
@@ -99,7 +99,7 @@ const StampPositionFormattingSettings = ({ parameters, onParameterChange, disabl
onParameterChange('fontSize', typeof v === 'number' ? v : 1)}
+ onChange={(v) => onParameterChange('fontSize', typeof v === 'number' && v > 0 ? v : 1)}
min={1}
max={400}
step={1}
@@ -114,6 +114,7 @@ const StampPositionFormattingSettings = ({ parameters, onParameterChange, disabl
max={400}
step={1}
className={styles.slider}
+ disabled={disabled}
/>
diff --git a/frontend/src/core/components/tools/addStamp/StampSetupSettings.tsx b/frontend/src/core/components/tools/addStamp/StampSetupSettings.tsx
index 17b8732c4..54e95b157 100644
--- a/frontend/src/core/components/tools/addStamp/StampSetupSettings.tsx
+++ b/frontend/src/core/components/tools/addStamp/StampSetupSettings.tsx
@@ -1,18 +1,169 @@
+import { useMemo } from "react";
import { useTranslation } from "react-i18next";
-import { Stack, Textarea, TextInput, Select, Button, Text, Divider } from "@mantine/core";
+import { Stack, Textarea, TextInput, Select, Button, Text, Divider, Accordion, Code, Group, Badge, Box, Paper } from "@mantine/core";
import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
import styles from "@app/components/tools/addStamp/StampPreview.module.css";
import { getDefaultFontSizeForAlphabet } from "@app/components/tools/addStamp/StampPreviewUtils";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
+const STAMP_TEMPLATES = [
+ {
+ id: 'page-numbers',
+ name: 'Page Numbers',
+ text: 'Page @page_number of @total_pages',
+ position: 2, // bottom center
+ },
+ {
+ id: 'draft',
+ name: 'Draft Watermark',
+ text: 'DRAFT - @date',
+ position: 5, // center
+ },
+ {
+ id: 'doc-info',
+ name: 'Document Info',
+ text: '@filename\nCreated: @date{dd MMM yyyy}',
+ position: 7, // top left
+ },
+ {
+ id: 'legal-footer',
+ name: 'Legal Footer',
+ text: '© @year - All Rights Reserved\n@filename - Page @page_number',
+ position: 2, // bottom center
+ },
+ {
+ id: 'european-date',
+ name: 'European Date (DD/MM/YYYY)',
+ text: '@date{dd/MM/yyyy}',
+ position: 9, // top right
+ },
+ {
+ id: 'timestamp',
+ name: 'Timestamp',
+ text: '@date{dd/MM/yyyy HH:mm}',
+ position: 9, // top right
+ },
+];
+
+const resolveVariablesForPreview = (text: string, filename?: string): string => {
+ const now = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+
+ const ESCAPED_AT_PLACEHOLDER = '\uE000ESCAPED_AT\uE000';
+ let result = text.replace(/@@/g, ESCAPED_AT_PLACEHOLDER);
+
+ const actualFilename = filename || 'sample-document.pdf';
+ const filenameWithoutExt = actualFilename.includes('.')
+ ? actualFilename.substring(0, actualFilename.lastIndexOf('.'))
+ : actualFilename;
+
+ const sampleData: Record = {
+ '@datetime': `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
+ '@date': `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
+ '@time': `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
+ '@year': String(now.getFullYear()),
+ '@month': pad(now.getMonth() + 1),
+ '@day': pad(now.getDate()),
+ // Page info - cannot be previewed, show placeholder
+ '@page_number': '?',
+ '@page': '?',
+ '@total_pages': '?',
+ '@page_count': '?',
+ // Filename - use actual file if provided
+ '@filename': filenameWithoutExt,
+ '@filename_full': actualFilename,
+ // Metadata - cannot be read from PDF in frontend, show placeholder
+ '@author': '?',
+ '@title': '?',
+ '@subject': '?',
+ // UUID - will be random each time
+ '@uuid': '????????',
+ };
+
+ result = result.replace(/@date\{([^}]+)\}/g, (match, format) => {
+ try {
+ return format
+ .replace(/yyyy/g, String(now.getFullYear()))
+ .replace(/yy/g, String(now.getFullYear()).slice(-2))
+ .replace(/MMMM/g, now.toLocaleString('default', { month: 'long' }))
+ .replace(/MMM/g, now.toLocaleString('default', { month: 'short' }))
+ .replace(/MM/g, pad(now.getMonth() + 1))
+ .replace(/dd/g, pad(now.getDate()))
+ .replace(/HH/g, pad(now.getHours()))
+ .replace(/hh/g, pad(now.getHours() % 12 || 12))
+ .replace(/mm/g, pad(now.getMinutes()))
+ .replace(/ss/g, pad(now.getSeconds()));
+ } catch {
+ return match;
+ }
+ });
+
+ Object.entries(sampleData).forEach(([key, value]) => {
+ result = result.split(key).join(value);
+ });
+
+ result = result.replace(new RegExp(ESCAPED_AT_PLACEHOLDER, 'g'), '@');
+
+ result = result.replace(/\\n/g, '\n');
+
+ return result;
+};
+
+interface ClickableCodeProps {
+ children: React.ReactNode;
+ onClick: () => void;
+ block?: boolean;
+}
+
+const ClickableCode = ({ children, onClick, block = false }: ClickableCodeProps) => (
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onClick();
+ }
+ }}
+ >
+ {children}
+
+);
+
+const StampTextPreview = ({ stampText, filename }: { stampText: string; filename?: string }) => {
+ const { t } = useTranslation();
+
+ const resolvedText = useMemo(() => {
+ if (!stampText.trim()) return '';
+ return resolveVariablesForPreview(stampText, filename);
+ }, [stampText, filename]);
+
+ if (!stampText.trim()) return null;
+
+ return (
+
+ {t('AddStampRequest.preview', 'Preview:')}
+
+ {resolvedText}
+
+
+ );
+};
+
interface StampSetupSettingsProps {
parameters: AddStampParameters;
onParameterChange: (key: K, value: AddStampParameters[K]) => void;
disabled?: boolean;
+ filename?: string;
}
-const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }: StampSetupSettingsProps) => {
+const StampSetupSettings = ({ parameters, onParameterChange, disabled = false, filename }: StampSetupSettingsProps) => {
const { t } = useTranslation();
return (
@@ -41,14 +192,172 @@ const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }:
{parameters.stampType === 'text' && (
<>
+ {/* Template Selector - always shows placeholder, doesn't persist selection */}
+