From fca40e5544a58d3bff993572ec7470b8a33117bd Mon Sep 17 00:00:00 2001 From: Dexterity <173429049+Dexterity104@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:54:13 -0400 Subject: [PATCH] Fix image stamp cropping and align preview with PDF output for add-stamp (#6013) --- .../controller/api/misc/StampController.java | 37 +++++++++-- .../api/misc/StampControllerTest.java | 61 +++++++++++++++++++ .../tools/addStamp/StampPreview.tsx | 26 +++++--- .../tools/addStamp/StampPreviewUtils.ts | 8 ++- 4 files changed, 117 insertions(+), 15 deletions(-) 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 78f381fa7f..bd596bd12b 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 @@ -483,9 +483,19 @@ public class StampController { y = overrideY; } else { x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin); - y = calculatePositionY(pageSize, position, desiredPhysicalHeight, margin); + // drawImage() places the lower-left corner at (x, y); use image-specific Y logic + y = calculateImagePositionY(pageSize, position, desiredPhysicalHeight, margin); } + float llx = pageSize.getLowerLeftX(); + float lly = pageSize.getLowerLeftY(); + float urx = pageSize.getUpperRightX(); + float ury = pageSize.getUpperRightY(); + float xMax = Math.max(llx, urx - desiredPhysicalWidth); + float yMax = Math.max(lly, ury - desiredPhysicalHeight); + x = Math.min(xMax, Math.max(llx, x)); + y = Math.min(yMax, Math.max(lly, y)); + contentStream.saveGraphicsState(); contentStream.transform(Matrix.getTranslateInstance(x, y)); contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0)); @@ -495,18 +505,37 @@ public class StampController { private float calculatePositionX( PDRectangle pageSize, int position, float contentWidth, float margin) { + float llx = pageSize.getLowerLeftX(); + float urx = pageSize.getUpperRightX(); return switch (position % 3) { case 1: // Left - yield pageSize.getLowerLeftX() + margin; + yield llx + margin; case 2: // Center - yield (pageSize.getWidth() - contentWidth) / 2; + yield llx + (pageSize.getWidth() - contentWidth) / 2; case 0: // Right - yield pageSize.getUpperRightX() - contentWidth - margin; + yield urx - contentWidth - margin; default: yield 0; }; } + private float calculateImagePositionY( + PDRectangle pageSize, int position, float imageHeight, float margin) { + float lly = pageSize.getLowerLeftY(); + float pageHeight = pageSize.getHeight(); + float ury = pageSize.getUpperRightY(); + return switch ((position - 1) / 3) { + case 0: // Top - upper image edge flush below top margin + yield ury - margin - imageHeight; + case 1: // Middle - center image on page + yield lly + (pageHeight - imageHeight) / 2; + case 2: // Bottom - lower image edge at bottom margin + yield lly + margin; + default: + yield lly; + }; + } + private float calculatePositionY( PDRectangle pageSize, int position, float height, float margin) { return switch ((position - 1) / 3) { 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 index 158abf486e..2a8b548ffd 100644 --- 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 @@ -9,6 +9,7 @@ import java.util.regex.Pattern; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -46,6 +47,7 @@ class StampControllerTest { private Method processStampTextMethod; private Method processCustomDateFormatMethod; + private Method calculateImagePositionYMethod; @BeforeEach void setUp() throws NoSuchMethodException { @@ -63,6 +65,26 @@ class StampControllerTest { StampController.class.getDeclaredMethod( "processCustomDateFormat", String.class, LocalDateTime.class); processCustomDateFormatMethod.setAccessible(true); + + calculateImagePositionYMethod = + StampController.class.getDeclaredMethod( + "calculateImagePositionY", + PDRectangle.class, + int.class, + float.class, + float.class); + calculateImagePositionYMethod.setAccessible(true); + } + + private float invokeCalculateImagePositionY( + PDRectangle pageSize, int position, float imageHeight, float margin) throws Exception { + try { + return (float) + calculateImagePositionYMethod.invoke( + stampController, pageSize, position, imageHeight, margin); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } } private String invokeProcessStampText( @@ -86,6 +108,45 @@ class StampControllerTest { } } + @Nested + @DisplayName("Image stamp position (lower-left anchor)") + class ImagePositionYTests { + + @Test + @DisplayName("Top row: upper edge of image sits below top margin") + void topRowUsesUpperRightMinusMarginMinusHeight() throws Exception { + PDRectangle page = new PDRectangle(0, 0, 600, 800); + float y = invokeCalculateImagePositionY(page, 3, 100f, 10f); + assertEquals(690f, y, 0.001f); + } + + @Test + @DisplayName("Middle row: image is vertically centred on page") + void middleRowCentresImage() throws Exception { + PDRectangle page = new PDRectangle(0, 0, 600, 800); + float y = invokeCalculateImagePositionY(page, 5, 100f, 10f); + assertEquals(350f, y, 0.001f); + } + + @Test + @DisplayName("Bottom row: lower edge of image sits above bottom margin") + void bottomRowUsesLowerLeftPlusMargin() throws Exception { + PDRectangle page = new PDRectangle(0, 0, 600, 800); + float y = invokeCalculateImagePositionY(page, 7, 100f, 10f); + assertEquals(10f, y, 0.001f); + } + + @Test + @DisplayName("Honours non-zero media box origin") + void respectsLowerLeftOrigin() throws Exception { + PDRectangle page = new PDRectangle(50f, 100f, 400f, 300f); + float yMid = invokeCalculateImagePositionY(page, 5, 20f, 5f); + assertEquals(240f, yMid, 0.001f); + float yTop = invokeCalculateImagePositionY(page, 3, 20f, 5f); + assertEquals(375f, yTop, 0.001f); + } + } + @Nested @DisplayName("Basic Variable Substitution Tests") class BasicVariableTests { diff --git a/frontend/src/core/components/tools/addStamp/StampPreview.tsx b/frontend/src/core/components/tools/addStamp/StampPreview.tsx index ebc6905502..b5d52a34ee 100644 --- a/frontend/src/core/components/tools/addStamp/StampPreview.tsx +++ b/frontend/src/core/components/tools/addStamp/StampPreview.tsx @@ -63,7 +63,8 @@ export default function StampPreview({ parameters, onParameterChange, file, show const buffer = await file.arrayBuffer(); const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true }); const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 1 }); + // Unrotated viewport keeps points to pixels aligned and avoids width and height swaps + const viewport = page.getViewport({ scale: 1, rotation: 0 }); if (!cancelled) { setPageSize({ widthPts: viewport.width, heightPts: viewport.height }); } @@ -143,8 +144,10 @@ export default function StampPreview({ parameters, onParameterChange, file, show const heightPts = pageSize?.heightPts ?? 841.89; const scaleX = containerSize.width / widthPts; const scaleY = containerSize.height / heightPts; - const newLeftPts = Math.max(0, Math.min(containerSize.width, newLeftPx)) / scaleX; - const newBottomPts = Math.max(0, Math.min(containerSize.height, newBottomPx)) / scaleY; + const maxLeftPx = Math.max(0, containerSize.width - widthPx); + const maxBottomPx = Math.max(0, containerSize.height - heightPx); + const newLeftPts = Math.max(0, Math.min(maxLeftPx, newLeftPx)) / scaleX; + const newBottomPts = Math.max(0, Math.min(maxBottomPx, newBottomPx)) / scaleY; onParameterChange('overrideX', newLeftPts as any); onParameterChange('overrideY', newBottomPts as any); } @@ -153,7 +156,7 @@ export default function StampPreview({ parameters, onParameterChange, file, show }, [parameters.fontSize, style.item, containerSize, pageSize, showQuickGrid, parameters.overrideX, parameters.overrideY, onParameterChange]); // Drag/resize/rotate interactions - const draggingRef = useRef<{ type: 'move' | 'resize' | 'rotate'; startX: number; startY: number; initLeft: number; initBottom: number; initHeight: number; centerX: number; centerY: number } | null>(null); + const draggingRef = useRef<{ type: 'move' | 'resize' | 'rotate'; startX: number; startY: number; initLeft: number; initBottom: number; initWidth: number; initHeight: number; centerX: number; centerY: number } | null>(null); const ensureOverrides = () => { const pageWidth = containerSize.width; @@ -164,13 +167,17 @@ export default function StampPreview({ parameters, onParameterChange, file, show const itemStyle = style.item as any; const leftPx = parseFloat(String(itemStyle.left).replace('px', '')) || 0; const bottomPx = parseFloat(String(itemStyle.bottom).replace('px', '')) || 0; + const widthPx = parseFloat(String(itemStyle.width).replace('px', '')) || 0; + const heightPx = parseFloat(String(itemStyle.height).replace('px', '')) || 0; const widthPts = pageSize?.widthPts ?? 595.28; const heightPts = pageSize?.heightPts ?? 841.89; const scaleX = containerSize.width / widthPts; const scaleY = containerSize.height / heightPts; if (parameters.overrideX < 0 || parameters.overrideY < 0) { - onParameterChange('overrideX', Math.max(0, Math.min(pageWidth, leftPx)) / scaleX as any); - onParameterChange('overrideY', Math.max(0, Math.min(pageHeight, bottomPx)) / scaleY as any); + const maxLeftPx = Math.max(0, pageWidth - widthPx); + const maxBottomPx = Math.max(0, pageHeight - heightPx); + onParameterChange('overrideX', Math.max(0, Math.min(maxLeftPx, leftPx)) / scaleX as any); + onParameterChange('overrideY', Math.max(0, Math.min(maxBottomPx, bottomPx)) / scaleY as any); } }; @@ -194,6 +201,7 @@ export default function StampPreview({ parameters, onParameterChange, file, show startY: (rect ? rect.bottom - e.clientY : 0), // convert to bottom-based coords initLeft: left, initBottom: bottom, + initWidth: width, initHeight: height, centerX, centerY, @@ -215,8 +223,10 @@ export default function StampPreview({ parameters, onParameterChange, file, show if (drag.type === 'move') { const dx = x - drag.startX; const dy = y - drag.startY; - const newLeftPx = Math.max(0, Math.min(containerSize.width, drag.initLeft + dx)); - const newBottomPx = Math.max(0, Math.min(containerSize.height, drag.initBottom + dy)); + const maxLeftPx = Math.max(0, containerSize.width - drag.initWidth); + const maxBottomPx = Math.max(0, containerSize.height - drag.initHeight); + const newLeftPx = Math.max(0, Math.min(maxLeftPx, drag.initLeft + dx)); + const newBottomPx = Math.max(0, Math.min(maxBottomPx, drag.initBottom + dy)); const widthPts = pageSize?.widthPts ?? 595.28; const heightPts = pageSize?.heightPts ?? 841.89; const scaleX = containerSize.width / widthPts; diff --git a/frontend/src/core/components/tools/addStamp/StampPreviewUtils.ts b/frontend/src/core/components/tools/addStamp/StampPreviewUtils.ts index 6d22ba0d7e..95b9629a7c 100644 --- a/frontend/src/core/components/tools/addStamp/StampPreviewUtils.ts +++ b/frontend/src/core/components/tools/addStamp/StampPreviewUtils.ts @@ -88,10 +88,12 @@ export function computeStampPreviewStyle( const marginPts = (widthPts + heightPts) / 2 * (marginFactorMap[parameters.customMargin] ?? 0.035); // Compute content dimensions - const heightPtsContent = parameters.fontSize * getAlphabetPreviewScale(parameters.alphabet); + const heightPtsContent = + parameters.stampType === 'image' + ? parameters.fontSize + : parameters.fontSize * getAlphabetPreviewScale(parameters.alphabet); let widthPtsContent = heightPtsContent; - if (parameters.stampType === 'image' && imageMeta) { const aspect = imageMeta.width / imageMeta.height; widthPtsContent = heightPtsContent * aspect; @@ -222,7 +224,7 @@ export function computeStampPreviewStyle( height: `${heightPx}px`, opacity: displayOpacity, transform: `rotate(${-parameters.rotation}deg)`, - transformOrigin: 'center center', + transformOrigin: parameters.stampType === 'image' ? 'left bottom' : 'center center', color: parameters.customColor, display: 'flex', flexDirection: 'column',