From 1cd3e2846eaf5173a0f4608daee36b1aeed241d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:16:14 +0100 Subject: [PATCH] feat(stamp): add dynamic variables and templates for stamp text customization (#5546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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. --- ## 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 Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../controller/api/misc/StampController.java | 253 ++++++-- .../SPDF/model/api/misc/AddStampRequest.java | 4 +- .../api/misc/StampControllerTest.java | 567 ++++++++++++++++++ .../public/locales/en-GB/translation.toml | 33 + .../StampPositionFormattingSettings.tsx | 3 +- .../tools/addStamp/StampSetupSettings.tsx | 313 +++++++++- .../tools/addStamp/useAddStampParameters.ts | 2 +- frontend/src/core/tools/AddStamp.tsx | 1 + 8 files changed, 1113 insertions(+), 63 deletions(-) create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/StampControllerTest.java 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: + * + *

+ * + *

Page Information: + * + *

+ * + *

File Information: + * + *

+ * + *

Document Metadata: + * + *

+ * + *

Other: + * + *

+ */ + 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 */} +