feat(stamp): add dynamic variables and templates for stamp text customization (#5546)

# Description of Changes

This pull request improves the PDF stamping feature, particularly the
text stamping functionality, by introducing dynamic variables, improving
formatting flexibility, and refining positioning logic. The changes
include backend support for dynamic stamp variables (such as date, time,
filename, and metadata), improvements to text layout and positioning,
updates to the API and frontend for usability, and localization
enhancements for user guidance.

**Dynamic Stamp Variables and Text Processing:**

* Added support for dynamic variables in stamp text (e.g., `@date`,
`@time`, `@page_number`, `@filename`, `@uuid`, and metadata fields),
including custom date formats and escaping for literal `@` symbols. This
is handled by the new `processStampText` method in
`StampController.java`.
* Implemented validation and formatting for custom date variables,
ensuring only safe formats are accepted and providing user-friendly
error messages for invalid formats.

**Text Layout and Positioning Improvements:**

* Refactored text and image stamp positioning: now calculates line
heights, block heights, and widths for multi-line stamps, and adjusts
placement logic for more accurate alignment (top, center, bottom) and
margins.
* Updated the default font size for stamps to 40pt and improved font
size handling in both backend and frontend, including validation for
positive values

**API and Method Signature Updates:**

* Extended method signatures to include additional context (such as page
index and filename) for more powerful variable substitution in stamps
**Frontend and Localization Enhancements:**

* Added comprehensive help text, variable descriptions, and template
examples to the UI, making it easier for users to understand and use
dynamic stamp variables.
* Improved accessibility and clarity in the stamp formatting UI by
disabling controls appropriately and providing clearer descriptions.



<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
Balázs Szücs
2026-01-29 22:16:14 +01:00
committed by GitHub
parent 080faf9353
commit 1cd3e2846e
8 changed files with 1113 additions and 63 deletions

View File

@@ -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:
*
* <p>Date & Time:
*
* <ul>
* <li>@date - Current date (YYYY-MM-DD)
* <li>@time - Current time (HH:mm:ss)
* <li>@datetime - Current date and time (YYYY-MM-DD HH:mm:ss)
* <li>@date{format} - Custom date/time format (e.g., @date{dd/MM/yyyy})
* <li>@year - Current year (4 digits)
* <li>@month - Current month (01-12)
* <li>@day - Current day of month (01-31)
* </ul>
*
* <p>Page Information:
*
* <ul>
* <li>@page_number or @page - Current page number
* <li>@total_pages or @page_count - Total number of pages
* </ul>
*
* <p>File Information:
*
* <ul>
* <li>@filename - Original filename (without extension)
* <li>@filename_full - Original filename (with extension)
* </ul>
*
* <p>Document Metadata:
*
* <ul>
* <li>@author - Document author (from PDF metadata)
* <li>@title - Document title (from PDF metadata)
* <li>@subject - Document subject (from PDF metadata)
* </ul>
*
* <p>Other:
*
* <ul>
* <li>@uuid - Short unique identifier (8 characters)
* </ul>
*/
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;
}
}

View File

@@ -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;

View File

@@ -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", ">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);
}
}
}