Compare commits

...

2 Commits

Author SHA1 Message Date
EthanHealy01
6cdbf55264 update backend to support rotating and resizing about the centerpoint 2025-09-17 01:08:23 +01:00
EthanHealy01
b94154dea7 change requests 2025-09-17 00:35:39 +01:00
7 changed files with 140 additions and 79 deletions

View File

@ -239,34 +239,54 @@ public class StampController {
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);
}
// Split the stampText into multiple lines
String[] lines = stampText.split("\\\\n");
String[] lines = stampText.split("\\r?\\n|\\\\n");
// 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;
// Compute a single pivot for the entire text block to avoid line-by-line wobble
float capHeight = calculateTextCapHeight(font, fontSize);
float blockHeight = Math.max(lineHeight, lineHeight * Math.max(1, lines.length));
float maxWidth = 0f;
for (String ln : lines) {
maxWidth = Math.max(maxWidth, calculateTextWidth(ln, font, fontSize));
}
if (overrideX >= 0 && overrideY >= 0) {
// Use override values if provided
x = overrideX;
y = overrideY;
} else {
// Base positioning on the true multi-line block size
x = calculatePositionX(pageSize, position, maxWidth, null, 0, null, margin);
y = calculatePositionY(pageSize, position, blockHeight, margin);
}
// After anchoring the block, draw from the top line downward
float adjustedX = x;
float adjustedY = y;
float pivotX = adjustedX + maxWidth / 2f;
float pivotY = adjustedY + blockHeight / 2f;
// Apply rotation about the block center at the graphics state level
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(pivotX, pivotY));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.transform(Matrix.getTranslateInstance(-pivotX, -pivotY));
contentStream.beginText();
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
// Set the text matrix for each line with rotation
contentStream.setTextMatrix(
Matrix.getRotateInstance(Math.toRadians(rotation), x, y - (i * lineHeight)));
// Start from top line: yTop = adjustedY + blockHeight - capHeight
float yLine = adjustedY + blockHeight - capHeight - (i * lineHeight);
contentStream.setTextMatrix(Matrix.getTranslateInstance(adjustedX, yLine));
contentStream.showText(line);
}
contentStream.endText();
contentStream.restoreGraphicsState();
}
private void addImageStamp(
@ -310,9 +330,17 @@ public class StampController {
}
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
// Rotate and scale about the center of the image
float centerX = x + (desiredPhysicalWidth / 2f);
float centerY = y + (desiredPhysicalHeight / 2f);
contentStream.transform(Matrix.getTranslateInstance(centerX, centerY));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.drawImage(
xobject,
-desiredPhysicalWidth / 2f,
-desiredPhysicalHeight / 2f,
desiredPhysicalWidth,
desiredPhysicalHeight);
contentStream.restoreGraphicsState();
}

View File

@ -1,4 +1,5 @@
import { Button, Group, Stack, Text } from "@mantine/core";
import FitText from "./FitText";
export interface ButtonOption<T> {
value: T;
@ -13,6 +14,8 @@ interface ButtonSelectorProps<T> {
label?: string;
disabled?: boolean;
fullWidth?: boolean;
buttonClassName?: string;
textClassName?: string;
}
const ButtonSelector = <T extends string>({
@ -22,6 +25,8 @@ const ButtonSelector = <T extends string>({
label = undefined,
disabled = false,
fullWidth = true,
buttonClassName,
textClassName,
}: ButtonSelectorProps<T>) => {
return (
<Stack gap='var(--mantine-spacing-sm)'>
@ -41,6 +46,7 @@ const ButtonSelector = <T extends string>({
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
onClick={() => onChange(option.value)}
disabled={disabled || option.disabled}
className={buttonClassName}
style={{
flex: fullWidth ? 1 : undefined,
height: 'auto',
@ -48,7 +54,13 @@ const ButtonSelector = <T extends string>({
fontSize: 'var(--mantine-font-size-sm)'
}}
>
{option.label}
<FitText
text={option.label}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={textClassName}
/>
</Button>
))}
</Group>

View File

@ -127,7 +127,9 @@
/* Information text container */
.informationContainer {
background-color: var(--information-text-bg);
padding: 8px;
padding: 2px;
padding-left: 8px;
padding-right: 8px;
border-radius: 10px;
margin-top: 8px;
margin-bottom: 8px;
@ -138,8 +140,8 @@
}
.informationText {
font-size: 0.875rem;
font-weight: 500;
font-size: 0.75rem;
font-weight: 400;
color: var(--information-text-color);
}

View File

@ -110,6 +110,48 @@ export default function StampPreview({ parameters, onParameterChange, file, show
)
), [containerSize, parameters, imageMeta, pageSize, showQuickGrid, hoverTile, pageThumbnail]);
// Keep center fixed when scaling via slider (or any fontSize changes)
const prevDimsRef = useRef<{ fontSize: number; widthPx: number; heightPx: number; leftPx: number; bottomPx: number } | null>(null);
useEffect(() => {
const itemStyle = style.item as any;
if (!itemStyle || containerSize.width <= 0 || containerSize.height <= 0) return;
const parse = (v: any) => parseFloat(String(v).replace('px', '')) || 0;
const leftPx = parse(itemStyle.left);
const bottomPx = parse(itemStyle.bottom);
const widthPx = parse(itemStyle.width);
const heightPx = parse(itemStyle.height);
const prev = prevDimsRef.current;
const hasOverrides = parameters.overrideX >= 0 && parameters.overrideY >= 0;
const canAdjust = hasOverrides && !showQuickGrid;
if (
prev &&
canAdjust &&
parameters.fontSize !== prev.fontSize &&
prev.widthPx > 0 &&
prev.heightPx > 0 &&
widthPx > 0 &&
heightPx > 0
) {
const centerX = prev.leftPx + prev.widthPx / 2;
const centerY = prev.bottomPx + prev.heightPx / 2;
const newLeftPx = centerX - widthPx / 2;
const newBottomPx = centerY - heightPx / 2;
const widthPts = pageSize?.widthPts ?? 595.28;
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;
onParameterChange('overrideX', newLeftPts as any);
onParameterChange('overrideY', newBottomPts as any);
}
prevDimsRef.current = { fontSize: parameters.fontSize, widthPx, heightPx, leftPx, bottomPx };
}, [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);

View File

@ -192,7 +192,7 @@ export function computeStampPreviewStyle(
height: `${heightPx}px`,
opacity: displayOpacity,
transform: `rotate(${-parameters.rotation}deg)`,
transformOrigin: 'left bottom',
transformOrigin: 'center center',
color: parameters.customColor,
display: 'flex',
flexDirection: 'column',

View File

@ -9,9 +9,7 @@ export const buildAddStampFormData = (parameters: AddStampParameters, file: File
formData.append('pageNumbers', parameters.pageNumbers);
formData.append('customMargin', 'medium');
formData.append('position', String(parameters.position));
const effectiveFontSize = parameters.stampType === 'text'
? parameters.fontSize * 2.2 // upscale to match the preview size
: parameters.fontSize;
const effectiveFontSize = parameters.fontSize;
formData.append('fontSize', String(effectiveFontSize));
formData.append('rotation', String(parameters.rotation));
formData.append('opacity', String(parameters.opacity / 100));

View File

@ -11,7 +11,7 @@ import StampPreview from "../components/tools/addStamp/StampPreview";
import LocalIcon from "../components/shared/LocalIcon";
import styles from "../components/tools/addStamp/StampPreview.module.css";
import { Tooltip } from "../components/shared/Tooltip";
import FitText from "../components/shared/FitText";
import ButtonSelector from "../components/shared/ButtonSelector";
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@ -99,24 +99,17 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
<Stack gap="md" justify="space-between" flex={1}>
<div>
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
<Group className={styles.modeToggleGroup} grow>
<Button
variant={params.parameters.stampType === 'text' ? 'filled' : 'outline'}
className={styles.modeToggleButton}
onClick={() => params.updateParameter('stampType', 'text')}
disabled={endpointLoading}
>
{t('watermark.type.1', 'Text')}
</Button>
<Button
variant={params.parameters.stampType === 'image' ? 'filled' : 'outline'}
className={styles.modeToggleButton}
onClick={() => params.updateParameter('stampType', 'image')}
disabled={endpointLoading}
>
{t('watermark.type.2', 'Image')}
</Button>
</Group>
<ButtonSelector
value={params.parameters.stampType}
onChange={(v: 'text' | 'image') => params.updateParameter('stampType', v)}
options={[
{ value: 'text', label: t('watermark.type.1', 'Text') },
{ value: 'image', label: t('watermark.type.2', 'Image') },
]}
disabled={endpointLoading}
buttonClassName={styles.modeToggleButton}
textClassName={styles.modeToggleButtonText}
/>
</div>
{params.parameters.stampType === 'text' && (
@ -208,45 +201,31 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
<Stack gap="md" justify="space-between">
{/* Mode toggle: Quick grid vs Custom drag - only show for image stamps */}
{params.parameters.stampType === 'image' && (
<Group className={styles.modeToggleGroup} grow>
<Button
variant={quickPositionModeSelected ? 'filled' : 'outline'}
className={styles.modeToggleButton}
onClick={() => {
setQuickPositionModeSelected(true);
setCustomPositionModeSelected(false);
}}
>
<FitText
text={t('quickPosition', 'Quick Position')}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={styles.modeToggleButtonText}
/>
</Button>
<Button
variant={customPositionModeSelected ? 'filled' : 'outline'}
className={styles.modeToggleButton}
onClick={() => {
setQuickPositionModeSelected(false);
setCustomPositionModeSelected(true);
}}
>
<FitText
text={t('customPosition', 'Custom Position')}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={styles.modeToggleButtonText}
/>
</Button>
</Group>
<ButtonSelector
value={quickPositionModeSelected ? 'quick' : 'custom'}
onChange={(v: 'quick' | 'custom') => {
const isQuick = v === 'quick';
setQuickPositionModeSelected(isQuick);
setCustomPositionModeSelected(!isQuick);
}}
options={[
{ value: 'quick', label: t('quickPosition', 'Quick Position') },
{ value: 'custom', label: t('customPosition', 'Custom Position') },
]}
disabled={endpointLoading}
buttonClassName={styles.modeToggleButton}
textClassName={styles.modeToggleButtonText}
/>
)}
{params.parameters.stampType === 'image' && customPositionModeSelected && (
<div className={styles.informationContainer}>
<Text className={styles.informationText}>{t('customPosition', 'Drag the stamp to the desired location in the preview window.')}</Text>
<Text className={styles.informationText}>{t('AddStampRequest.customPosition', 'Drag the stamp to the desired location in the preview window.')}</Text>
</div>
)}
{params.parameters.stampType === 'image' && !customPositionModeSelected && (
<div className={styles.informationContainer}>
<Text className={styles.informationText}>{t('AddStampRequest.quickPosition', 'Select a position on the page to place the stamp.')}</Text>
</div>
)}