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 8b6aa09e7..9f7005bcc 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 @@ -237,34 +237,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( @@ -308,9 +328,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(); } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 6f87ab5ce..d0b10b478 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2382,6 +2382,7 @@ "tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize,Customise", "header": "Stamp PDF", "title": "Stamp PDF", + "stampSetup": "Stamp Setup", "stampType": "Stamp Type", "stampText": "Stamp Text", "stampImage": "Stamp Image", @@ -2394,7 +2395,8 @@ "overrideY": "Override Y Coordinate", "customMargin": "Custom Margin", "customColor": "Custom Text Colour", - "submit": "Submit" + "submit": "Submit", + "noStampSelected": "No stamp selected. Return to Step 1." }, "removeImagePdf": { "tags": "Remove Image,Page operations,Back end,server side" diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 5d8a23faa..4cea62d96 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -1470,6 +1470,7 @@ "tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize", "header": "Stamp PDF", "title": "Stamp PDF", + "stampSetup": "Stamp Setup", "stampType": "Stamp Type", "stampText": "Stamp Text", "stampImage": "Stamp Image", @@ -1482,7 +1483,8 @@ "overrideY": "Override Y Coordinate", "customMargin": "Custom Margin", "customColor": "Custom Text Color", - "submit": "Submit" + "submit": "Submit", + "noStampSelected": "No stamp selected. Return to Step 1." }, "removeImagePdf": { "tags": "Remove Image,Page operations,Back end,server side" diff --git a/frontend/src/components/shared/ButtonSelector.tsx b/frontend/src/components/shared/ButtonSelector.tsx index 79d92bf25..97b109ef2 100644 --- a/frontend/src/components/shared/ButtonSelector.tsx +++ b/frontend/src/components/shared/ButtonSelector.tsx @@ -1,4 +1,5 @@ import { Button, Group, Stack, Text } from "@mantine/core"; +import FitText from "./FitText"; export interface ButtonOption { value: T; @@ -13,6 +14,8 @@ interface ButtonSelectorProps { label?: string; disabled?: boolean; fullWidth?: boolean; + buttonClassName?: string; + textClassName?: string; } const ButtonSelector = ({ @@ -22,6 +25,8 @@ const ButtonSelector = ({ label = undefined, disabled = false, fullWidth = true, + buttonClassName, + textClassName, }: ButtonSelectorProps) => { return ( @@ -41,6 +46,7 @@ const ButtonSelector = ({ 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', @@ -51,7 +57,13 @@ const ButtonSelector = ({ paddingBottom: '0.5rem' }} > - {option.label} + ))} diff --git a/frontend/src/components/shared/ObscuredOverlay.tsx b/frontend/src/components/shared/ObscuredOverlay.tsx new file mode 100644 index 000000000..0e918151f --- /dev/null +++ b/frontend/src/components/shared/ObscuredOverlay.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import styles from './ObscuredOverlay/ObscuredOverlay.module.css'; + +type ObscuredOverlayProps = { + obscured: boolean; + overlayMessage?: React.ReactNode; + buttonText?: string; + onButtonClick?: () => void; + children: React.ReactNode; + // Optional border radius for the overlay container. If undefined, no radius is applied. + borderRadius?: string | number; +}; + +export default function ObscuredOverlay({ + obscured, + overlayMessage, + buttonText, + onButtonClick, + children, + borderRadius, +}: ObscuredOverlayProps) { + return ( +
+ {children} + {obscured && ( +
+
+ {overlayMessage && ( +
+ {overlayMessage} +
+ )} + {buttonText && onButtonClick && ( + + )} +
+
+ )} +
+ ); +} + + diff --git a/frontend/src/components/shared/ObscuredOverlay/ObscuredOverlay.module.css b/frontend/src/components/shared/ObscuredOverlay/ObscuredOverlay.module.css new file mode 100644 index 000000000..5651993c4 --- /dev/null +++ b/frontend/src/components/shared/ObscuredOverlay/ObscuredOverlay.module.css @@ -0,0 +1,41 @@ +.container { + position: relative; +} + +.overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 16px; + color: #ffffff; + font-weight: 600; + background: rgba(16, 18, 27, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border: 1px solid rgba(255, 255, 255, 0.06); + z-index: 2; +} + +.overlayContent { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; +} + +.overlayMessage { + color: #ffffff; + font-weight: 600; +} + +.overlayButton { + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: #ffffff; + cursor: pointer; +} diff --git a/frontend/src/components/tools/addStamp/StampPreview.module.css b/frontend/src/components/tools/addStamp/StampPreview.module.css new file mode 100644 index 000000000..779d7bf1d --- /dev/null +++ b/frontend/src/components/tools/addStamp/StampPreview.module.css @@ -0,0 +1,195 @@ +/* StampPreview.module.css */ + +/* Container styles */ +.container { + position: relative; + width: 100%; + overflow: hidden; +} + +.containerWithThumbnail { + background-color: transparent; +} + +.containerWithoutThumbnail { + background-color: rgba(255, 255, 255, 0.03); +} + +.containerBorder { + border: 1px solid var(--border-default, #333); +} + +/* Page thumbnail styles */ +.pageThumbnail { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + filter: grayscale(10%) contrast(95%) brightness(105%); +} + +/* Stamp item styles */ +.stampItem { + position: absolute; + display: flex; + flex-direction: column; + justify-content: flex-start; + line-height: 1; + transform-origin: left bottom; +} + +.stampItemDraggable { + cursor: move; + pointer-events: auto; +} + +.stampItemGridMode { + cursor: default; + pointer-events: none; +} + +/* Text stamp styles */ +.textLine { + white-space: pre; + display: block; + word-break: keep-all; + overflow: visible; +} + +/* Image stamp styles */ +.stampImage { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Quick grid overlay styles */ +.quickGrid { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 8px; + padding: 8px; + pointer-events: auto; +} + +.gridTile { + border: 1px dashed rgba(0, 0, 0, 0.15); + background-color: transparent; + color: transparent; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + user-select: none; +} + +.gridTileSelected, +.gridTileHovered { + border: 2px solid var(--mantine-primary-color-filled, #3b82f6); +} + +/* Preview header */ +.previewHeader { + margin-bottom: 12px; +} + +.divider { + height: 1px; + background-color: var(--border-default, #333); + margin-bottom: 8px; +} + +.previewLabel { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + text-align: center; +} + +/* Preview disclaimer */ +.previewDisclaimer { + margin-top: 8px; + opacity: 0.7; + font-size: 12px; +} + +/* AddStamp.tsx specific styles */ + +/* Information text container */ +.informationContainer { + background-color: var(--information-text-bg); + padding: 2px; + padding-left: 8px; + padding-right: 8px; + border-radius: 10px; + margin-top: 8px; + margin-bottom: 8px; + width: 100%; + align-items: center; + justify-content: center; + text-align: center; +} + +.informationText { + font-size: 0.75rem; + font-weight: 400; + color: var(--information-text-color); +} + +/* Mode toggle buttons */ +.modeToggleGroup { + gap: 0.25rem; + flex-grow: 1; +} + +.modeToggleButton { + border-radius: 0.125rem; + font-size: 0.75rem; + width: 100%; +} + +/* Icon pill buttons */ +.iconPillGroup { + gap: 0.25rem; + flex-grow: 1; +} + +.iconPillButton { + border-radius: 0.125rem; + font-size: 0.75rem; + width: 100%; +} + +/* Slider controls */ +.sliderGroup { + gap: 1rem; + align-items: center; +} + +.numberInput { + width: 80px; + font-size: 0.875rem; +} + +.slider { + flex: 1; +} + +.sliderWide { + flex: 1.2; +} + +/* Label text */ +.labelText { + font-size: 0.875rem; + font-weight: 500; +} diff --git a/frontend/src/components/tools/addStamp/StampPreview.tsx b/frontend/src/components/tools/addStamp/StampPreview.tsx new file mode 100644 index 000000000..b6299f474 --- /dev/null +++ b/frontend/src/components/tools/addStamp/StampPreview.tsx @@ -0,0 +1,343 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { AddStampParameters } from './useAddStampParameters'; +import { pdfWorkerManager } from '../../../services/pdfWorkerManager'; +import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration'; +import { A4_ASPECT_RATIO, getFirstSelectedPage, getFontFamily, computeStampPreviewStyle, getAlphabetPreviewScale } from './StampPreviewUtils'; +import styles from './StampPreview.module.css'; + +type Props = { + parameters: AddStampParameters; + onParameterChange: (key: K, value: AddStampParameters[K]) => void; + file?: File | null; + showQuickGrid?: boolean; +}; + +export default function StampPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) { + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + const [imageMeta, setImageMeta] = useState<{ url: string; width: number; height: number } | null>(null); + const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null); + const [pageThumbnail, setPageThumbnail] = useState(null); + const { requestThumbnail } = useThumbnailGeneration(); + const [hoverTile, setHoverTile] = useState(null); + + // Load image URL and meta for aspect ratio if an image is selected + useEffect(() => { + if (parameters.stampType === 'image' && parameters.stampImage) { + const url = URL.createObjectURL(parameters.stampImage); + const img = new Image(); + img.onload = () => { + setImageMeta({ url, width: img.width, height: img.height }); + }; + img.src = url; + return () => URL.revokeObjectURL(url); + } else { + setImageMeta(null); + } + }, [parameters.stampType, parameters.stampImage]); + + // Observe container size for responsive positioning + useEffect(() => { + const node = containerRef.current; + if (!node) return; + const resize = () => { + const aspect = pageSize ? (pageSize.widthPts / pageSize.heightPts) : A4_ASPECT_RATIO; + setContainerSize({ width: node.clientWidth, height: node.clientWidth / aspect }); + }; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(node); + return () => ro.disconnect(); + }, [pageSize]); + + // Load first PDF page size in points for accurate scaling + useEffect(() => { + let cancelled = false; + const load = async () => { + if (!file || file.type !== 'application/pdf') { + setPageSize(null); + return; + } + try { + 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 }); + if (!cancelled) { + setPageSize({ widthPts: viewport.width, heightPts: viewport.height }); + } + pdfWorkerManager.destroyDocument(pdf); + } catch { + // Fallback to A4 if we cannot read page + if (!cancelled) setPageSize(null); + } + }; + load(); + return () => { cancelled = true; }; + }, [file]); + + // Load first-page thumbnail for background preview so users see the content + useEffect(() => { + let isActive = true; + const loadThumb = async () => { + if (!file || file.type !== 'application/pdf') { + setPageThumbnail(null); + return; + } + try { + const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pageNumbers)); + const pageId = `${file.name}:${file.size}:${file.lastModified}:page:${pageNumber}`; + const thumb = await requestThumbnail(pageId, file, pageNumber); + if (isActive) setPageThumbnail(thumb || null); + } catch { + if (isActive) setPageThumbnail(null); + } + }; + loadThumb(); + return () => { isActive = false; }; + }, [file, parameters.pageNumbers, requestThumbnail]); + + const style = useMemo(() => ( + computeStampPreviewStyle( + parameters, + imageMeta, + pageSize, + containerSize, + showQuickGrid, + hoverTile, + !!pageThumbnail + ) + ), [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); + + const ensureOverrides = () => { + const pageWidth = containerSize.width; + const pageHeight = containerSize.height; + if (pageWidth <= 0 || pageHeight <= 0) return; + + // Recompute current x,y from style (so that we start from visual position) + 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 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 handlePointerDown = (e: React.PointerEvent, type: 'move' | 'resize' | 'rotate') => { + e.preventDefault(); + ensureOverrides(); + + const item = style.item as any; + const left = parseFloat(String(item.left).replace('px', '')) || 0; + const bottom = parseFloat(String(item.bottom).replace('px', '')) || 0; + const width = parseFloat(String(item.width).replace('px', '')) || parameters.fontSize; + const height = parseFloat(String(item.height).replace('px', '')) || parameters.fontSize; + + const rect = (e.currentTarget.parentElement as HTMLElement)?.getBoundingClientRect(); + const centerX = left + width / 2; + const centerY = bottom + height / 2; + + draggingRef.current = { + type, + startX: e.clientX - (rect?.left || 0), + startY: (rect ? rect.bottom - e.clientY : 0), // convert to bottom-based coords + initLeft: left, + initBottom: bottom, + initHeight: height, + centerX, + centerY, + }; + + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!draggingRef.current) return; + const node = containerRef.current; + if (!node) return; + const rect = node.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = rect.bottom - e.clientY; // bottom-based + + const drag = draggingRef.current; + + 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 widthPts = pageSize?.widthPts ?? 595.28; + const heightPts = pageSize?.heightPts ?? 841.89; + const scaleX = containerSize.width / widthPts; + const scaleY = containerSize.height / heightPts; + const newLeftPts = newLeftPx / scaleX; + const newBottomPts = newBottomPx / scaleY; + onParameterChange('overrideX', newLeftPts as any); + onParameterChange('overrideY', newBottomPts as any); + } + + if (drag.type === 'resize') { + // Height is our canonical size (fontSize) + const heightPts = pageSize?.heightPts ?? 841.89; + const scaleY = containerSize.height / heightPts; + const newHeightPx = Math.max(1, drag.initHeight + (y - drag.startY)); + const newHeightPts = newHeightPx / scaleY; + onParameterChange('fontSize', newHeightPts as any); + } + + if (drag.type === 'rotate') { + const angle = Math.atan2(y - drag.centerY, x - drag.centerX) * (180 / Math.PI); + onParameterChange('rotation', angle as any); + } + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!draggingRef.current) return; + draggingRef.current = null; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + }; + + const itemHandles = null; // Drag-only per request + + return ( +
+
+
+
Preview Stamp
+
+
+ {pageThumbnail && ( + page preview + )} + {parameters.stampType === 'text' && ( +
+ {(parameters.stampText || '').split('\n').map((line, idx) => ( + + {line || '\u00A0'} + + ))} + {itemHandles} +
+ )} + {parameters.stampType === 'image' && imageMeta && ( +
handlePointerDown(e, 'move')} + > + stamp preview + {itemHandles} +
+ )} + + {/* Quick position overlay grid */} + {showQuickGrid && ( +
+ {Array.from({ length: 9 }).map((_, i) => { + const idx = (i + 1) as 1|2|3|4|5|6|7|8|9; + const selected = parameters.position === idx && (parameters.overrideX < 0 || parameters.overrideY < 0); + return ( + + ); + })} +
+ )} +
+
+ Preview is approximate. Final output may vary due to PDF font metrics. +
+
+ ); +} + + diff --git a/frontend/src/components/tools/addStamp/StampPreviewUtils.ts b/frontend/src/components/tools/addStamp/StampPreviewUtils.ts new file mode 100644 index 000000000..0001aec45 --- /dev/null +++ b/frontend/src/components/tools/addStamp/StampPreviewUtils.ts @@ -0,0 +1,235 @@ +import type { AddStampParameters } from './useAddStampParameters'; + +export type ContainerSize = { width: number; height: number }; +export type PageSizePts = { widthPts: number; heightPts: number } | null; +export type ImageMeta = { url: string; width: number; height: number } | null; + +// Map UI margin option to backend margin factor +export const marginFactorMap: Record = { + 'small': 0.02, + 'medium': 0.035, + 'large': 0.05, + 'x-large': 0.075, +}; + +export const A4_ASPECT_RATIO = 0.707; // width/height used elsewhere in legacy UI + +// Get font family based on selected alphabet (matching backend logic) +export const getFontFamily = (alphabet: string): string => { + switch (alphabet) { + case 'arabic': + return 'Noto Sans Arabic, Arial Unicode MS, sans-serif'; + case 'japanese': + return 'Meiryo, Yu Gothic, Hiragino Sans, sans-serif'; + case 'korean': + return 'Malgun Gothic, Dotum, sans-serif'; + case 'chinese': + return 'SimSun, Microsoft YaHei, sans-serif'; + case 'thai': + return 'Noto Sans Thai, Tahoma, sans-serif'; + case 'roman': + default: + return 'Noto Sans, Arial, Helvetica, sans-serif'; + } +}; + +// Lightweight parser: returns first page number from CSV/range input, otherwise 1 +export const getFirstSelectedPage = (input: string): number => { + if (!input) return 1; + const parts = input.split(',').map(s => s.trim()).filter(Boolean); + for (const part of parts) { + if (/^\d+\s*-\s*\d+$/.test(part)) { + const low = parseInt(part.split('-')[0].trim(), 10); + if (Number.isFinite(low) && low > 0) return low; + } + const n = parseInt(part, 10); + if (Number.isFinite(n) && n > 0) return n; + } + return 1; +}; + +export type StampPreviewStyle = { container: any; item: any }; + +// Unified per-alphabet preview adjustments +export type Alphabet = 'roman' | 'arabic' | 'japanese' | 'korean' | 'chinese' | 'thai'; +export type AlphabetTweaks = { scale: number; rowOffsetRem: [number, number, number]; lineHeight: number; capHeightRatio: number; defaultFontSize: number }; +export const ALPHABET_PREVIEW_TWEAKS: Record = { + // [top, middle, bottom] row offsets in rem + roman: { scale: 1.0/1.18, rowOffsetRem: [0, 1, 2.2], lineHeight: 1.28, capHeightRatio: 0.70, defaultFontSize: 80 }, + arabic: { scale: 1.2, rowOffsetRem: [0, 1.5, 2.5], lineHeight: 1, capHeightRatio: 0.68, defaultFontSize: 80 }, + japanese: { scale: 1/1.2, rowOffsetRem: [-0.1, 1, 2], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 80 }, + korean: { scale: 1.0/1.05, rowOffsetRem: [-0.2, 0.5, 1.4], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 80 }, + chinese: { scale: 1/1.2, rowOffsetRem: [0, 2, 2.8], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 30 }, // temporary default font size so that it fits on the PDF + thai: { scale: 1/1.2, rowOffsetRem: [-1, 0, .8], lineHeight: 1, capHeightRatio: 0.66, defaultFontSize: 80 }, +}; +export const getAlphabetPreviewScale = (alphabet: string): number => (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.scale ?? 1.0; + +export const getDefaultFontSizeForAlphabet = (alphabet: string): number => { + return (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.defaultFontSize ?? 80; +}; + +export function computeStampPreviewStyle( + parameters: AddStampParameters, + imageMeta: ImageMeta, + pageSize: PageSizePts, + containerSize: ContainerSize, + showQuickGrid: boolean | undefined, + _hoverTile: number | null, + hasPageThumbnail: boolean +): StampPreviewStyle { + const pageWidthPx = containerSize.width; + const pageHeightPx = containerSize.height; + const widthPts = pageSize?.widthPts ?? 595.28; // A4 width at 72 DPI + const heightPts = pageSize?.heightPts ?? 841.89; // A4 height at 72 DPI + const scaleX = pageWidthPx / widthPts; + const scaleY = pageHeightPx / heightPts; + if (pageWidthPx <= 0 || pageHeightPx <= 0) return { item: {}, container: {} } as any; + + const marginPts = (widthPts + heightPts) / 2 * (marginFactorMap[parameters.customMargin] ?? 0.035); + + // Compute content dimensions + const heightPtsContent = parameters.fontSize * getAlphabetPreviewScale(parameters.alphabet); + let widthPtsContent = heightPtsContent; + + + if (parameters.stampType === 'image' && imageMeta) { + const aspect = imageMeta.width / imageMeta.height; + widthPtsContent = heightPtsContent * aspect; + } else if (parameters.stampType === 'text') { + // Use Canvas 2D to measure text width for better fidelity than DOM spans + const textLine = (parameters.stampText || '').split('\n')[0] ?? ''; + const fontPx = heightPtsContent * scaleY; // Convert point size to px using vertical scale + const fontFamily = getFontFamily(parameters.alphabet); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.font = `${fontPx}px ${fontFamily}`; + const metrics = ctx.measureText(textLine); + const measuredWidthPx = metrics.width; + // Convert measured px width back to PDF points using horizontal scale + widthPtsContent = measuredWidthPx / scaleX; + + let adjustmentFactor = 1.0; + switch (parameters.alphabet) { + case 'roman': + adjustmentFactor = 0.90; + break; + case 'arabic': + case 'thai': + adjustmentFactor = 0.92; + break; + case 'japanese': + case 'korean': + case 'chinese': + adjustmentFactor = 0.88; + break; + default: + adjustmentFactor = 0.93; + } + widthPtsContent *= adjustmentFactor; + } + } + + // Positioning helpers - mirror backend logic + const position = parameters.position; + const calcX = () => { + if (parameters.overrideX >= 0 && parameters.overrideY >= 0) return parameters.overrideX; + switch (position % 3) { + case 1: // Left + return marginPts; + case 2: // Center + return (widthPts - widthPtsContent) / 2; + case 0: // Right + return widthPts - widthPtsContent - marginPts; + default: + return 0; + } + }; + const calcY = () => { + if (parameters.overrideX >= 0 && parameters.overrideY >= 0) return parameters.overrideY; + // For text, backend positions using cap height, not full font size + const heightForY = parameters.stampType === 'text' + ? heightPtsContent * ((ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.capHeightRatio ?? 0.70) + : heightPtsContent; + switch (Math.floor((position - 1) / 3)) { + case 0: // Top + return heightPts - heightForY - marginPts; + case 1: // Middle + return (heightPts - heightForY) / 2; + case 2: // Bottom + return marginPts; + default: + return 0; + } + }; + + const xPts = calcX(); + const yPts = calcY(); + let xPx = xPts * scaleX; + let yPx = yPts * scaleY; + if (parameters.stampType === 'text') { + try { + const rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16; + const rowIndex = Math.floor((position - 1) / 3); // 0 top, 1 middle, 2 bottom + const offsets = (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.rowOffsetRem ?? [0, 0, 0]; + const offsetRem = offsets[rowIndex] ?? 0; + yPx += offsetRem * rootFontSizePx; + } catch (e) { + // no-op + console.error(e); + } + } + const widthPx = widthPtsContent * scaleX; + const heightPx = heightPtsContent * scaleY; + + xPx = Math.max(0, Math.min(xPx, pageWidthPx - widthPx)); + yPx = Math.max(0, Math.min(yPx, pageHeightPx - heightPx)); + + const opacity = Math.max(0, Math.min(1, parameters.opacity / 100)); + const displayOpacity = opacity; + + let alignItems: 'flex-start' | 'center' | 'flex-end' = 'flex-start'; + if (parameters.stampType === 'text') { + const colIndex = position % 3; // 1: left, 2: center, 0: right + switch (colIndex) { + case 2: // center column + alignItems = 'center'; + break; + case 0: // right column + alignItems = 'flex-end'; + break; + default: + alignItems = 'flex-start'; + } + } + + return { + container: { + position: 'relative', + width: '100%', + aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`, + backgroundColor: hasPageThumbnail ? 'transparent' : 'rgba(255,255,255,0.03)', + border: '1px solid var(--border-default, #333)', + overflow: 'hidden' + }, + item: { + position: 'absolute', + left: `${xPx}px`, + bottom: `${yPx}px`, + width: `${widthPx}px`, + height: `${heightPx}px`, + opacity: displayOpacity, + transform: `rotate(${-parameters.rotation}deg)`, + transformOrigin: 'center center', + color: parameters.customColor, + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + lineHeight: (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.lineHeight ?? 1, + alignItems, + cursor: showQuickGrid ? 'default' : 'move', + pointerEvents: showQuickGrid ? 'none' : 'auto', + } + }; +} diff --git a/frontend/src/components/tools/addStamp/useAddStampOperation.ts b/frontend/src/components/tools/addStamp/useAddStampOperation.ts new file mode 100644 index 000000000..c41c440fc --- /dev/null +++ b/frontend/src/components/tools/addStamp/useAddStampOperation.ts @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../../../hooks/tools/shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { AddStampParameters, defaultParameters } from './useAddStampParameters'; + +export const buildAddStampFormData = (parameters: AddStampParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('pageNumbers', parameters.pageNumbers); + formData.append('customMargin', parameters.customMargin || 'medium'); + formData.append('position', String(parameters.position)); + const effectiveFontSize = parameters.fontSize; + formData.append('fontSize', String(effectiveFontSize)); + formData.append('rotation', String(parameters.rotation)); + formData.append('opacity', String(parameters.opacity / 100)); + formData.append('overrideX', String(parameters.overrideX)); + formData.append('overrideY', String(parameters.overrideY)); + formData.append('customColor', parameters.customColor.startsWith('#') ? parameters.customColor : `#${parameters.customColor}`); + formData.append('alphabet', parameters.alphabet); + + // Stamp type and payload + formData.append('stampType', parameters.stampType || 'text'); + if (parameters.stampType === 'text') { + formData.append('stampText', parameters.stampText); + } else if (parameters.stampType === 'image' && parameters.stampImage) { + formData.append('stampImage', parameters.stampImage); + } + + return formData; +}; + +export const addStampOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildAddStampFormData, + operationType: 'addStamp', + endpoint: '/api/v1/misc/add-stamp', + defaultParameters, +} as const; + +export const useAddStampOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...addStampOperationConfig, + getErrorMessage: createStandardErrorHandler( + t('AddStampRequest.error.failed', 'An error occurred while adding stamp to the PDF.') + ), + }); +}; + + diff --git a/frontend/src/components/tools/addStamp/useAddStampParameters.ts b/frontend/src/components/tools/addStamp/useAddStampParameters.ts new file mode 100644 index 000000000..3df4900f8 --- /dev/null +++ b/frontend/src/components/tools/addStamp/useAddStampParameters.ts @@ -0,0 +1,53 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, type BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters'; + +export interface AddStampParameters extends BaseParameters { + stampType?: 'text' | 'image'; + stampText: string; + stampImage?: File; + alphabet: 'roman' | 'arabic' | 'japanese' | 'korean' | 'chinese' | 'thai'; + fontSize: number; + rotation: number; + opacity: number; + position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + overrideX: number; + overrideY: number; + customMargin: 'small' | 'medium' | 'large' | 'x-large'; + customColor: string; + pageNumbers: string; + _activePill: 'fontSize' | 'rotation' | 'opacity'; +} + +export const defaultParameters: AddStampParameters = { + stampType: 'text', + stampText: '', + alphabet: 'roman', + fontSize: 80, + rotation: 0, + opacity: 50, + position: 5, + overrideX: -1, + overrideY: -1, + customMargin: 'medium', + customColor: '#d3d3d3', + pageNumbers: '1', + _activePill: 'fontSize', +}; + +export type AddStampParametersHook = BaseParametersHook; + +export const useAddStampParameters = (): AddStampParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'add-stamp', + validateFn: (params): boolean => { + if (!params.stampType) return false; + if (params.stampType === 'text') { + return params.stampText.trim().length > 0; + } + return params.stampImage !== undefined; + }, + }); +}; + + diff --git a/frontend/src/components/tools/shared/ReviewToolStep.tsx b/frontend/src/components/tools/shared/ReviewToolStep.tsx index 6dcb6fc6c..243e787b5 100644 --- a/frontend/src/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/src/components/tools/shared/ReviewToolStep.tsx @@ -15,6 +15,8 @@ export interface ReviewToolStepProps { title?: string; onFileClick?: (file: File) => void; onUndo: () => void; + isCollapsed?: boolean; + onCollapsedClick?: () => void; } function ReviewStepContent({ @@ -111,6 +113,8 @@ export function createReviewToolStep( t("review", "Review"), { isVisible: props.isVisible, + isCollapsed: props.isCollapsed, + onCollapsedClick: props.onCollapsedClick, _excludeFromCount: true, _noPadding: true, }, diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index 9ea94bc4f..4724648c8 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -113,4 +113,4 @@ export function createToolFlow(config: ToolFlowConfig) { ); -} +} \ No newline at end of file diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 76cf9e7bd..6ea1d83b0 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -13,6 +13,7 @@ import RemovePages from "../tools/RemovePages"; import RemovePassword from "../tools/RemovePassword"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; import AddWatermark from "../tools/AddWatermark"; +import AddStamp from "../tools/AddStamp"; import Merge from '../tools/Merge'; import Repair from "../tools/Repair"; import AutoRename from "../tools/AutoRename"; @@ -32,6 +33,7 @@ import { removePasswordOperationConfig } from "../hooks/tools/removePassword/use import { sanitizeOperationConfig } from "../hooks/tools/sanitize/useSanitizeOperation"; import { repairOperationConfig } from "../hooks/tools/repair/useRepairOperation"; import { addWatermarkOperationConfig } from "../hooks/tools/addWatermark/useAddWatermarkOperation"; +import { addStampOperationConfig } from "../components/tools/addStamp/useAddStampOperation"; import { unlockPdfFormsOperationConfig } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation"; import { singleLargePageOperationConfig } from "../hooks/tools/singleLargePage/useSingleLargePageOperation"; import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation"; @@ -213,10 +215,13 @@ export function useFlatToolRegistry(): ToolRegistry { addStamp: { icon: , name: t("home.addStamp.title", "Add Stamp to PDF"), - component: null, + component: AddStamp, description: t("home.addStamp.desc", "Add text or add image stamps at set locations"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.DOCUMENT_SECURITY, + maxFiles: -1, + endpoints: ["add-stamp"], + operationConfig: addStampOperationConfig, }, sanitize: { icon: , diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 8095a165c..f5e5e91bf 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -191,6 +191,8 @@ --checkbox-checked-bg: #3FAFFF; --checkbox-tick: #FFFFFF; + --information-text-bg: #eaeaea; + --information-text-color: #5e5e5e; /* Bulk selection panel specific colors (light mode) */ --bulk-panel-bg: #ffffff; /* white background for parent container */ --bulk-card-bg: #ffffff; /* white background for cards */ @@ -351,6 +353,9 @@ /* Tool panel search bar background colors (dark mode) */ --tool-panel-search-bg: #1F2329; --tool-panel-search-border-bottom: #4B525A; + + --information-text-bg: #292e34; + --information-text-color: #ececec; /* Bulk selection panel specific colors (dark mode) */ --bulk-panel-bg: var(--bg-raised); /* dark background for parent container */ diff --git a/frontend/src/tools/AddStamp.tsx b/frontend/src/tools/AddStamp.tsx new file mode 100644 index 000000000..61cb45c84 --- /dev/null +++ b/frontend/src/tools/AddStamp.tsx @@ -0,0 +1,413 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useFileSelection } from "../contexts/FileContext"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useAddStampParameters } from "../components/tools/addStamp/useAddStampParameters"; +import { useAddStampOperation } from "../components/tools/addStamp/useAddStampOperation"; +import { Group, Select, Stack, Textarea, TextInput, ColorInput, Button, Slider, Text, NumberInput, Divider } from "@mantine/core"; +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 ButtonSelector from "../components/shared/ButtonSelector"; +import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps"; +import ObscuredOverlay from "../components/shared/ObscuredOverlay"; +import { getDefaultFontSizeForAlphabet } from "../components/tools/addStamp/StampPreviewUtils"; + +const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { selectedFiles } = useFileSelection(); + + const [quickPositionModeSelected, setQuickPositionModeSelected] = useState(false); + const [customPositionModeSelected, setCustomPositionModeSelected] = useState(true); + + const params = useAddStampParameters(); + const operation = useAddStampOperation(); + + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-stamp"); + + useEffect(() => { + operation.resetResults(); + onPreviewFile?.(null); + }, [params.parameters]); + + + const handleExecute = async () => { + try { + await operation.executeOperation(params.parameters, selectedFiles); + if (operation.files && onComplete) { + onComplete(operation.files); + } + } catch (error: any) { + onError?.(error?.message || t("AddStampRequest.error.failed", "Add stamp operation failed")); + } + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = operation.files.length > 0 || operation.downloadUrl !== null; + + enum AddStampStep { + NONE = 'none', + STAMP_SETUP = 'stampSetup', + POSITION_FORMATTING = 'positionFormatting' + } + + const accordion = useAccordionSteps({ + noneValue: AddStampStep.NONE, + initialStep: AddStampStep.STAMP_SETUP, + stateConditions: { + hasFiles, + hasResults + }, + afterResults: () => { + operation.resetResults(); + onPreviewFile?.(null); + } + }); + + const getSteps = () => { + const steps: any[] = []; + + // Step 1: Stamp Setup + steps.push({ + title: t("AddStampRequest.stampSetup", "Stamp Setup"), + isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP), + onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_SETUP), + isVisible: hasFiles || hasResults, + content: ( + + params.updateParameter('pageNumbers', e.currentTarget.value)} + disabled={endpointLoading} + /> + +
+ {t('AddStampRequest.stampType', 'Stamp Type')} + 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} + /> +
+ + {params.parameters.stampType === 'text' && ( + <> +