add page numbers

This commit is contained in:
Anthony Stirling 2025-09-25 21:38:52 +01:00
parent 21b1428ab5
commit 77d8a1c59a
7 changed files with 652 additions and 7 deletions

View File

@ -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",

View File

@ -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;
}

View File

@ -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: <K extends keyof AddPageNumbersParameters>(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<HTMLDivElement>(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<string | null>(null);
const { requestThumbnail } = useThumbnailGeneration();
const [hoverTile, setHoverTile] = useState<number | null>(null);
const [textColor, setTextColor] = useState<string>('#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 (
<div>
<div className={styles.previewHeader}>
<div className={styles.divider} />
<div className={styles.previewLabel}>{t('addPageNumbers.preview', 'Preview Page Numbers')}</div>
</div>
<div
ref={containerRef}
className={`${styles.container} ${styles.containerBorder} ${pageThumbnail ? styles.containerWithThumbnail : styles.containerWithoutThumbnail}`}
style={containerStyle}
>
{pageThumbnail && (
<img
src={pageThumbnail}
alt="page preview"
className={styles.pageThumbnail}
draggable={false}
/>
)}
{/* Quick position overlay grid - EXACT copy from stamp */}
{showQuickGrid && (
<div className={styles.quickGrid}>
{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 (
<button
key={idx}
type="button"
className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ''} ${hoverTile === idx ? styles.gridTileHovered : ''}`}
onClick={() => onParameterChange('position', idx as any)}
onMouseEnter={() => setHoverTile(idx)}
onMouseLeave={() => setHoverTile(null)}
style={{
color: textColor,
textShadow: textColor === '#fff'
? '1px 1px 2px rgba(0, 0, 0, 0.8)'
: '1px 1px 2px rgba(255, 255, 255, 0.8)'
}}
>
{idx}
</button>
);
})}
</div>
)}
</div>
<div className={styles.previewDisclaimer}>
{t('addPageNumbers.previewDisclaimer', 'Preview is approximate. Final output may vary due to PDF font metrics.')}
</div>
</div>
);
}

View File

@ -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<AddPageNumbersParameters>({
...addPageNumbersOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('addPageNumbers.error.failed', 'An error occurred while adding page numbers to the PDF.')
),
});
};

View File

@ -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<AddPageNumbersParameters>;
export const useAddPageNumbersParameters = (): AddPageNumbersParametersHook => {
return useBaseParameters<AddPageNumbersParameters>({
defaultParameters,
endpointName: 'add-page-numbers',
validateFn: (params): boolean => {
return params.fontSize > 0 && params.startingNumber > 0;
},
});
};

View File

@ -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: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
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: {

View File

@ -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<AddPageNumbersStep>({
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: (
<Stack gap="lg">
{/* Position Selection */}
<Stack gap="md">
<PageNumberPreview
parameters={params.parameters}
onParameterChange={params.updateParameter}
file={selectedFiles[0] || null}
showQuickGrid={true}
/>
</Stack>
<Divider />
{/* Pages & Starting Number Section */}
<Stack gap="md">
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
<Tooltip content={t('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.')}>
<TextInput
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
value={params.parameters.pagesToNumber}
onChange={(e) => params.updateParameter('pagesToNumber', e.currentTarget.value)}
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
<NumberInput
label={t('addPageNumbers.selectText.4', 'Starting Number')}
value={params.parameters.startingNumber}
onChange={(v) => params.updateParameter('startingNumber', typeof v === 'number' ? v : 1)}
min={1}
disabled={endpointLoading}
/>
</Tooltip>
</Stack>
</Stack>
),
});
// 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: (
<Stack gap="md">
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
<Select
label={t('addPageNumbers.selectText.2', 'Margin')}
value={params.parameters.customMargin}
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('sizes.small', 'Small') },
{ value: 'medium', label: t('sizes.medium', 'Medium') },
{ value: 'large', label: t('sizes.large', 'Large') },
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
]}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
<NumberInput
label={t('addPageNumbers.fontSize', 'Font Size')}
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 12)}
min={1}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
<Select
label={t('addPageNumbers.fontName', 'Font Type')}
value={params.parameters.fontType}
onChange={(v) => params.updateParameter('fontType', (v as any) || 'Times')}
data={[
{ value: 'Times', label: 'Times Roman' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Courier', label: 'Courier New' },
]}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
<TextInput
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
value={params.parameters.customText}
onChange={(e) => params.updateParameter('customText', e.currentTarget.value)}
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
disabled={endpointLoading}
/>
</Tooltip>
</Stack>
),
});
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;