Fix image stamp cropping and align preview with PDF output for add-stamp (#6013)

This commit is contained in:
Dexterity
2026-04-02 17:54:13 +01:00
committed by GitHub
parent c9a70f3754
commit fca40e5544
4 changed files with 117 additions and 15 deletions
@@ -483,9 +483,19 @@ public class StampController {
y = overrideY; y = overrideY;
} else { } else {
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin); 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.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y)); contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0)); contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
@@ -495,18 +505,37 @@ public class StampController {
private float calculatePositionX( private float calculatePositionX(
PDRectangle pageSize, int position, float contentWidth, float margin) { PDRectangle pageSize, int position, float contentWidth, float margin) {
float llx = pageSize.getLowerLeftX();
float urx = pageSize.getUpperRightX();
return switch (position % 3) { return switch (position % 3) {
case 1: // Left case 1: // Left
yield pageSize.getLowerLeftX() + margin; yield llx + margin;
case 2: // Center case 2: // Center
yield (pageSize.getWidth() - contentWidth) / 2; yield llx + (pageSize.getWidth() - contentWidth) / 2;
case 0: // Right case 0: // Right
yield pageSize.getUpperRightX() - contentWidth - margin; yield urx - contentWidth - margin;
default: default:
yield 0; 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( private float calculatePositionY(
PDRectangle pageSize, int position, float height, float margin) { PDRectangle pageSize, int position, float height, float margin) {
return switch ((position - 1) / 3) { return switch ((position - 1) / 3) {
@@ -9,6 +9,7 @@ import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
@@ -46,6 +47,7 @@ class StampControllerTest {
private Method processStampTextMethod; private Method processStampTextMethod;
private Method processCustomDateFormatMethod; private Method processCustomDateFormatMethod;
private Method calculateImagePositionYMethod;
@BeforeEach @BeforeEach
void setUp() throws NoSuchMethodException { void setUp() throws NoSuchMethodException {
@@ -63,6 +65,26 @@ class StampControllerTest {
StampController.class.getDeclaredMethod( StampController.class.getDeclaredMethod(
"processCustomDateFormat", String.class, LocalDateTime.class); "processCustomDateFormat", String.class, LocalDateTime.class);
processCustomDateFormatMethod.setAccessible(true); 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( 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 @Nested
@DisplayName("Basic Variable Substitution Tests") @DisplayName("Basic Variable Substitution Tests")
class BasicVariableTests { class BasicVariableTests {
@@ -63,7 +63,8 @@ export default function StampPreview({ parameters, onParameterChange, file, show
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true }); const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true });
const page = await pdf.getPage(1); 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) { if (!cancelled) {
setPageSize({ widthPts: viewport.width, heightPts: viewport.height }); 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 heightPts = pageSize?.heightPts ?? 841.89;
const scaleX = containerSize.width / widthPts; const scaleX = containerSize.width / widthPts;
const scaleY = containerSize.height / heightPts; const scaleY = containerSize.height / heightPts;
const newLeftPts = Math.max(0, Math.min(containerSize.width, newLeftPx)) / scaleX; const maxLeftPx = Math.max(0, containerSize.width - widthPx);
const newBottomPts = Math.max(0, Math.min(containerSize.height, newBottomPx)) / scaleY; 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('overrideX', newLeftPts as any);
onParameterChange('overrideY', newBottomPts 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]); }, [parameters.fontSize, style.item, containerSize, pageSize, showQuickGrid, parameters.overrideX, parameters.overrideY, onParameterChange]);
// Drag/resize/rotate interactions // 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 ensureOverrides = () => {
const pageWidth = containerSize.width; const pageWidth = containerSize.width;
@@ -164,13 +167,17 @@ export default function StampPreview({ parameters, onParameterChange, file, show
const itemStyle = style.item as any; const itemStyle = style.item as any;
const leftPx = parseFloat(String(itemStyle.left).replace('px', '')) || 0; const leftPx = parseFloat(String(itemStyle.left).replace('px', '')) || 0;
const bottomPx = parseFloat(String(itemStyle.bottom).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 widthPts = pageSize?.widthPts ?? 595.28;
const heightPts = pageSize?.heightPts ?? 841.89; const heightPts = pageSize?.heightPts ?? 841.89;
const scaleX = containerSize.width / widthPts; const scaleX = containerSize.width / widthPts;
const scaleY = containerSize.height / heightPts; const scaleY = containerSize.height / heightPts;
if (parameters.overrideX < 0 || parameters.overrideY < 0) { if (parameters.overrideX < 0 || parameters.overrideY < 0) {
onParameterChange('overrideX', Math.max(0, Math.min(pageWidth, leftPx)) / scaleX as any); const maxLeftPx = Math.max(0, pageWidth - widthPx);
onParameterChange('overrideY', Math.max(0, Math.min(pageHeight, bottomPx)) / scaleY as any); 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 startY: (rect ? rect.bottom - e.clientY : 0), // convert to bottom-based coords
initLeft: left, initLeft: left,
initBottom: bottom, initBottom: bottom,
initWidth: width,
initHeight: height, initHeight: height,
centerX, centerX,
centerY, centerY,
@@ -215,8 +223,10 @@ export default function StampPreview({ parameters, onParameterChange, file, show
if (drag.type === 'move') { if (drag.type === 'move') {
const dx = x - drag.startX; const dx = x - drag.startX;
const dy = y - drag.startY; const dy = y - drag.startY;
const newLeftPx = Math.max(0, Math.min(containerSize.width, drag.initLeft + dx)); const maxLeftPx = Math.max(0, containerSize.width - drag.initWidth);
const newBottomPx = Math.max(0, Math.min(containerSize.height, drag.initBottom + dy)); 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 widthPts = pageSize?.widthPts ?? 595.28;
const heightPts = pageSize?.heightPts ?? 841.89; const heightPts = pageSize?.heightPts ?? 841.89;
const scaleX = containerSize.width / widthPts; const scaleX = containerSize.width / widthPts;
@@ -88,10 +88,12 @@ export function computeStampPreviewStyle(
const marginPts = (widthPts + heightPts) / 2 * (marginFactorMap[parameters.customMargin] ?? 0.035); const marginPts = (widthPts + heightPts) / 2 * (marginFactorMap[parameters.customMargin] ?? 0.035);
// Compute content dimensions // 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; let widthPtsContent = heightPtsContent;
if (parameters.stampType === 'image' && imageMeta) { if (parameters.stampType === 'image' && imageMeta) {
const aspect = imageMeta.width / imageMeta.height; const aspect = imageMeta.width / imageMeta.height;
widthPtsContent = heightPtsContent * aspect; widthPtsContent = heightPtsContent * aspect;
@@ -222,7 +224,7 @@ export function computeStampPreviewStyle(
height: `${heightPx}px`, height: `${heightPx}px`,
opacity: displayOpacity, opacity: displayOpacity,
transform: `rotate(${-parameters.rotation}deg)`, transform: `rotate(${-parameters.rotation}deg)`,
transformOrigin: 'center center', transformOrigin: parameters.stampType === 'image' ? 'left bottom' : 'center center',
color: parameters.customColor, color: parameters.customColor,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',