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