diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 6b2b527cf..b48a9e97a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -10,16 +10,34 @@ "selectText": { "1": "Select PDF file:", "2": "Margin Size", - "3": "Position", + "3": "Position Selection", "4": "Starting Number", "5": "Pages to Number", - "6": "Custom Text" + "6": "Custom Text Format" }, "customTextDesc": "Custom Text", - "numberPagesDesc": "Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc", - "customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}", - "submit": "Add Page Numbers" + "numberPagesDesc": "e.g., 1,3,5-8 or leave blank for all pages", + "customNumberDesc": "e.g., \"Page {n}\" or leave blank for just numbers", + "submit": "Add Page Numbers", + "configuration": "Configuration", + "customize": "Customize Appearance", + "pagesAndStarting": "Pages & Starting Number", + "positionAndPages": "Position & Pages", + "error": { + "failed": "Add page numbers operation failed" + }, + "results": { + "title": "Page Number Results" + }, + "preview": "Position Selection", + "previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics." }, + "pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.", + "startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.", + "marginTooltip": "Distance between the page number and the edge of the page.", + "fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.", + "fontTypeTooltip": "Font family for the page numbers. Choose based on your document style.", + "customTextTooltip": "Optional custom format for page numbers. Use {n} as placeholder for the number. Example: \"Page {n}\" will show \"Page 1\", \"Page 2\", etc.", "pdfPrompt": "Select PDF(s)", "multiPdfPrompt": "Select PDFs (2+)", "multiPdfDropPrompt": "Select (or drag & drop) all PDFs you require", diff --git a/frontend/src/components/tools/addPageNumbers/PageNumberPreview.module.css b/frontend/src/components/tools/addPageNumbers/PageNumberPreview.module.css new file mode 100644 index 000000000..1657472b1 --- /dev/null +++ b/frontend/src/components/tools/addPageNumbers/PageNumberPreview.module.css @@ -0,0 +1,101 @@ +/* PageNumberPreview.module.css - EXACT copy from StampPreview */ + +/* 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%); +} + +/* Quick grid overlay styles - EXACT copy from stamp */ +.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; + border-radius: 10px; + cursor: pointer; + display: flex; + font-size: 20px; + user-select: none; + font-weight: 600; + position: relative; +} + +/* Position numbers at edges within each tile with extra top/bottom spacing */ +.gridTile:nth-child(1) { align-items: flex-start; justify-content: flex-start; padding-top: 4px; } /* top-left */ +.gridTile:nth-child(2) { align-items: flex-start; justify-content: center; padding-top: 4px; } /* top-center */ +.gridTile:nth-child(3) { align-items: flex-start; justify-content: flex-end; padding-top: 4px; } /* top-right */ +.gridTile:nth-child(4) { align-items: center; justify-content: flex-start; } /* middle-left */ +.gridTile:nth-child(5) { align-items: center; justify-content: center; } /* center */ +.gridTile:nth-child(6) { align-items: center; justify-content: flex-end; } /* middle-right */ +.gridTile:nth-child(7) { align-items: flex-end; justify-content: flex-start; padding-bottom: 4px; } /* bottom-left */ +.gridTile:nth-child(8) { align-items: flex-end; justify-content: center; padding-bottom: 4px; } /* bottom-center */ +.gridTile:nth-child(9) { align-items: flex-end; justify-content: flex-end; padding-bottom: 4px; } /* bottom-right */ + +/* Base padding for all tiles */ +.gridTile { + padding: 8px; +} + +.gridTileSelected, +.gridTileHovered { + border: 2px solid var(--mantine-primary-color-filled, #3b82f6); + background-color: rgba(59, 130, 246, 0.2); +} + +/* 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; +} \ No newline at end of file diff --git a/frontend/src/components/tools/addPageNumbers/PageNumberPreview.tsx b/frontend/src/components/tools/addPageNumbers/PageNumberPreview.tsx new file mode 100644 index 000000000..5c12bc10c --- /dev/null +++ b/frontend/src/components/tools/addPageNumbers/PageNumberPreview.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AddPageNumbersParameters } from './useAddPageNumbersParameters'; +import { pdfWorkerManager } from '../../../services/pdfWorkerManager'; +import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration'; +import styles from './PageNumberPreview.module.css'; + +// Simple utilities for page numbers (adapted from stamp) +const A4_ASPECT_RATIO = 0.707; + +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; +}; + +const generatePreviewText = (parameters: AddPageNumbersParameters): string => { + const pageNum = parameters.startingNumber; + if (parameters.customText.trim()) { + return parameters.customText.replace(/\{n\}/g, pageNum.toString()); + } + return pageNum.toString(); +}; + +const detectOverallBackgroundColor = async (thumbnailSrc: string | null): Promise<'light' | 'dark'> => { + if (!thumbnailSrc) { + return 'light'; // Default to light background if no thumbnail + } + + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + resolve('light'); + return; + } + + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + + // Sample the entire image at reduced resolution for performance + const sampleWidth = Math.min(100, img.width); + const sampleHeight = Math.min(100, img.height); + const imageData = ctx.getImageData(0, 0, img.width, img.height); + const data = imageData.data; + + let totalBrightness = 0; + let pixelCount = 0; + + // Sample every nth pixel for performance + const step = Math.max(1, Math.floor((img.width * img.height) / (sampleWidth * sampleHeight))); + + for (let i = 0; i < data.length; i += 4 * step) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // Calculate perceived brightness using luminance formula + const brightness = (0.299 * r + 0.587 * g + 0.114 * b); + totalBrightness += brightness; + pixelCount++; + } + + const averageBrightness = totalBrightness / pixelCount; + + // Threshold: 128 is middle gray + resolve(averageBrightness > 128 ? 'light' : 'dark'); + } catch (error) { + console.warn('Error detecting background color:', error); + resolve('light'); // Default fallback + } + }; + + img.onerror = () => resolve('light'); + img.src = thumbnailSrc; + }); +}; + +type Props = { + parameters: AddPageNumbersParameters; + onParameterChange: (key: K, value: AddPageNumbersParameters[K]) => void; + file?: File | null; + showQuickGrid?: boolean; +}; + +export default function PageNumberPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) { + const { t } = useTranslation(); + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null); + const [pageThumbnail, setPageThumbnail] = useState(null); + const { requestThumbnail } = useThumbnailGeneration(); + const [hoverTile, setHoverTile] = useState(null); + const [textColor, setTextColor] = useState('#fff'); + + // 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 { + if (!cancelled) setPageSize(null); + } + }; + load(); + return () => { cancelled = true; }; + }, [file]); + + // Load first-page thumbnail for background preview + useEffect(() => { + let isActive = true; + const loadThumb = async () => { + if (!file || file.type !== 'application/pdf') { + setPageThumbnail(null); + return; + } + try { + const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pagesToNumber || '1')); + 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.pagesToNumber, requestThumbnail]); + + // Detect text color based on overall PDF background + useEffect(() => { + if (!pageThumbnail) { + setTextColor('#fff'); // Default to white for no thumbnail + return; + } + + const detectColor = async () => { + const backgroundType = await detectOverallBackgroundColor(pageThumbnail); + setTextColor(backgroundType === 'light' ? '#000' : '#fff'); + }; + + detectColor(); + }, [pageThumbnail]); + + const containerStyle = useMemo(() => ({ + position: 'relative' as const, + width: '100%', + aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`, + backgroundColor: pageThumbnail ? 'transparent' : 'rgba(255,255,255,0.03)', + border: '1px solid var(--border-default, #333)', + overflow: 'hidden' as const + }), [pageSize, pageThumbnail]); + + return ( +
+
+
+
{t('addPageNumbers.preview', 'Preview Page Numbers')}
+
+
+ {pageThumbnail && ( + page preview + )} + + {/* Quick position overlay grid - EXACT copy from stamp */} + {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; + return ( + + ); + })} +
+ )} +
+
+ {t('addPageNumbers.previewDisclaimer', 'Preview is approximate. Final output may vary due to PDF font metrics.')} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/tools/addPageNumbers/useAddPageNumbersOperation.ts b/frontend/src/components/tools/addPageNumbers/useAddPageNumbersOperation.ts new file mode 100644 index 000000000..775ee9009 --- /dev/null +++ b/frontend/src/components/tools/addPageNumbers/useAddPageNumbersOperation.ts @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../../../hooks/tools/shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { AddPageNumbersParameters, defaultParameters } from './useAddPageNumbersParameters'; + +export const buildAddPageNumbersFormData = (parameters: AddPageNumbersParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('customMargin', parameters.customMargin); + formData.append('position', String(parameters.position)); + formData.append('fontSize', String(parameters.fontSize)); + formData.append('fontType', parameters.fontType); + formData.append('startingNumber', String(parameters.startingNumber)); + formData.append('pagesToNumber', parameters.pagesToNumber); + formData.append('customText', parameters.customText); + + return formData; +}; + +export const addPageNumbersOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildAddPageNumbersFormData, + operationType: 'addPageNumbers', + endpoint: '/api/v1/misc/add-page-numbers', + defaultParameters, +} as const; + +export const useAddPageNumbersOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...addPageNumbersOperationConfig, + getErrorMessage: createStandardErrorHandler( + t('addPageNumbers.error.failed', 'An error occurred while adding page numbers to the PDF.') + ), + }); +}; \ No newline at end of file diff --git a/frontend/src/components/tools/addPageNumbers/useAddPageNumbersParameters.ts b/frontend/src/components/tools/addPageNumbers/useAddPageNumbersParameters.ts new file mode 100644 index 000000000..ca5c1e2e1 --- /dev/null +++ b/frontend/src/components/tools/addPageNumbers/useAddPageNumbersParameters.ts @@ -0,0 +1,34 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, type BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters'; + +export interface AddPageNumbersParameters extends BaseParameters { + customMargin: 'small' | 'medium' | 'large' | 'x-large'; + position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + fontSize: number; + fontType: 'Times' | 'Helvetica' | 'Courier'; + startingNumber: number; + pagesToNumber: string; + customText: string; +} + +export const defaultParameters: AddPageNumbersParameters = { + customMargin: 'medium', + position: 8, // Default to bottom center like the original HTML + fontSize: 12, + fontType: 'Times', + startingNumber: 1, + pagesToNumber: '', + customText: '', +}; + +export type AddPageNumbersParametersHook = BaseParametersHook; + +export const useAddPageNumbersParameters = (): AddPageNumbersParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'add-page-numbers', + validateFn: (params): boolean => { + return params.fontSize > 0 && params.startingNumber > 0; + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 5400b5e98..0d195ec89 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -74,6 +74,8 @@ import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/u import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings"; import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep"; import CropSettings from "../components/tools/crop/CropSettings"; +import AddPageNumbers from "../tools/AddPageNumbers"; +import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation"; const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI @@ -407,11 +409,13 @@ export function useFlatToolRegistry(): ToolRegistry { addPageNumbers: { icon: , name: t("home.addPageNumbers.title", "Add Page Numbers"), - component: null, - + component: AddPageNumbers, description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, + maxFiles: -1, + endpoints: ["add-page-numbers"], + operationConfig: addPageNumbersOperationConfig, synonyms: getSynonyms(t, "addPageNumbers") }, pageLayout: { diff --git a/frontend/src/tools/AddPageNumbers.tsx b/frontend/src/tools/AddPageNumbers.tsx new file mode 100644 index 000000000..992556491 --- /dev/null +++ b/frontend/src/tools/AddPageNumbers.tsx @@ -0,0 +1,203 @@ +import { useEffect } 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 { useAddPageNumbersParameters } from "../components/tools/addPageNumbers/useAddPageNumbersParameters"; +import { useAddPageNumbersOperation } from "../components/tools/addPageNumbers/useAddPageNumbersOperation"; +import { Select, Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core"; +import { Tooltip } from "../components/shared/Tooltip"; +import PageNumberPreview from "../components/tools/addPageNumbers/PageNumberPreview"; +import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps"; + +const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { selectedFiles } = useFileSelection(); + + const params = useAddPageNumbersParameters(); + const operation = useAddPageNumbersOperation(); + + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-page-numbers"); + + 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("addPageNumbers.error.failed", "Add page numbers operation failed")); + } + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = operation.files.length > 0 || operation.downloadUrl !== null; + + enum AddPageNumbersStep { + NONE = 'none', + POSITION_AND_PAGES = 'position_and_pages', + CUSTOMIZE = 'customize' + } + + const accordion = useAccordionSteps({ + noneValue: AddPageNumbersStep.NONE, + initialStep: AddPageNumbersStep.POSITION_AND_PAGES, + stateConditions: { + hasFiles, + hasResults + }, + afterResults: () => { + operation.resetResults(); + onPreviewFile?.(null); + } + }); + + const getSteps = () => { + const steps: any[] = []; + + // Step 1: Position Selection & Pages/Starting Number + steps.push({ + title: t("addPageNumbers.positionAndPages", "Position & Pages"), + isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.POSITION_AND_PAGES), + onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES), + isVisible: hasFiles || hasResults, + content: ( + + {/* Position Selection */} + + + + + + + {/* Pages & Starting Number Section */} + + {t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')} + + + params.updateParameter('pagesToNumber', e.currentTarget.value)} + placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')} + disabled={endpointLoading} + /> + + + + params.updateParameter('startingNumber', typeof v === 'number' ? v : 1)} + min={1} + disabled={endpointLoading} + /> + + + + ), + }); + + // Step 2: Customize Appearance + steps.push({ + title: t("addPageNumbers.customize", "Customize Appearance"), + isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.CUSTOMIZE), + onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.CUSTOMIZE), + isVisible: hasFiles || hasResults, + content: ( + + + params.updateParameter('fontType', (v as any) || 'Times')} + data={[ + { value: 'Times', label: 'Times Roman' }, + { value: 'Helvetica', label: 'Helvetica' }, + { value: 'Courier', label: 'Courier New' }, + ]} + disabled={endpointLoading} + /> + + + + params.updateParameter('customText', e.currentTarget.value)} + placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')} + disabled={endpointLoading} + /> + + + ), + }); + + return steps; + }; + + return createToolFlow({ + files: { + selectedFiles, + isCollapsed: hasResults, + }, + steps: getSteps(), + executeButton: { + text: t('addPageNumbers.submit', 'Add Page Numbers'), + isVisible: !hasResults, + loadingText: t('loading'), + onClick: handleExecute, + disabled: !params.validateParameters() || !hasFiles || !endpointEnabled, + }, + review: { + isVisible: hasResults, + operation: operation, + title: t('addPageNumbers.results.title', 'Page Number Results'), + onFileClick: (file) => onPreviewFile?.(file), + onUndo: async () => { + await operation.undoOperation(); + onPreviewFile?.(null); + }, + }, + }); +}; + +AddPageNumbers.tool = () => useAddPageNumbersOperation; + +export default AddPageNumbers as ToolComponent; \ No newline at end of file