From 233b710b78b525af0da2bf0b8391b45cb8238ad6 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:38:10 +0100 Subject: [PATCH 1/6] Convert extract-image-scans to React component (#4505) Co-authored-by: Claude --- .../SPDF/controller/api/MergeController.java | 14 +-- .../public/locales/en-GB/translation.json | 44 +++++-- .../ScannerImageSplitSettings.tsx | 74 +++++++++++ .../tooltips/useScannerImageSplitTips.ts | 54 ++++++++ .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../useScannerImageSplitOperation.ts | 54 ++++++++ .../useScannerImageSplitParameters.ts | 31 +++++ frontend/src/services/zipFileService.ts | 119 ++++++++++++++++++ frontend/src/tools/ScannerImageSplit.tsx | 58 +++++++++ 9 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/tools/scannerImageSplit/ScannerImageSplitSettings.tsx create mode 100644 frontend/src/components/tooltips/useScannerImageSplitTips.ts create mode 100644 frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts create mode 100644 frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitParameters.ts create mode 100644 frontend/src/tools/ScannerImageSplit.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 806dfdc38..50fc6e0c2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -229,7 +229,7 @@ public class MergeController { if (!invalidIndexes.isEmpty()) { // Parse client file IDs (always present from frontend) String[] clientIds = parseClientFileIds(request.getClientFileIds()); - + // Map invalid indexes to client IDs List errorFileIds = new ArrayList<>(); for (Integer index : invalidIndexes) { @@ -237,12 +237,12 @@ public class MergeController { errorFileIds.add(clientIds[index]); } } - - String payload = String.format( - "{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}", - errorFileIds.toString() - ); - + + String payload = + String.format( + "{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}", + errorFileIds.toString()); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .body(payload.getBytes(StandardCharsets.UTF_8)); diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c30bc7c55..17bafc6f4 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -436,8 +436,8 @@ }, "scannerImageSplit": { "tags": "detect,split,photos", - "title": "Detect/Split Scanned photos", - "desc": "Splits multiple photos from within a photo/PDF" + "title": "Detect & Split Scanned Photos", + "desc": "Detect and split scanned photos into separate pages" }, "sign": { "tags": "signature,autograph", @@ -1656,18 +1656,48 @@ "tags": "separate,auto-detect,scans,multi-photo,organize", "selectText": { "1": "Angle Threshold:", - "2": "Sets the minimum absolute angle required for the image to be rotated (default: 10).", + "2": "Tilt (in degrees) needed before we auto-straighten a photo.", "3": "Tolerance:", - "4": "Determines the range of colour variation around the estimated background colour (default: 30).", + "4": "How closely a colour must match the page background to count as background. Higher = looser, lower = stricter.", "5": "Minimum Area:", - "6": "Sets the minimum area threshold for a photo (default: 10000).", + "6": "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments.", "7": "Minimum Contour Area:", - "8": "Sets the minimum contour area threshold for a photo", + "8": "Smallest edge/shape we consider when finding photos (filters dust and specks).", "9": "Border Size:", - "10": "Sets the size of the border added and removed to prevent white borders in the output (default: 1)." + "10": "Extra padding (in pixels) around each saved photo so edges aren't cut." }, "info": "Python is not installed. It is required to run." }, + "scannerImageSplit": { + "title": "Extracted Images", + "submit": "Extract Image Scans", + "error": { + "failed": "An error occurred while extracting image scans." + }, + "tooltip": { + "title": "Photo Splitter", + "whatThisDoes": "What this does", + "whatThisDoesDesc": "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.", + "whenToUse": "When to use", + "useCase1": "Scan whole album pages in one go", + "useCase2": "Split flatbed batches into separate files", + "useCase3": "Break collages into individual photos", + "useCase4": "Pull photos from documents", + "quickFixes": "Quick fixes", + "problem1": "Photos not detected → increase Tolerance to 30-50", + "problem2": "Too many false detections → increase Minimum Area to 15,000-20,000", + "problem3": "Crops are too tight → increase Border Size to 5-10", + "problem4": "Tilted photos not straightened → lower Angle Threshold to ~5°", + "problem5": "Dust/noise boxes → increase Minimum Contour Area to 1000-2000", + "setupTips": "Setup tips", + "tip1": "Use a plain, light background", + "tip2": "Leave a small gap (≈1 cm) between photos", + "tip3": "Scan at 300-600 DPI", + "tip4": "Clean the scanner glass", + "headsUp": "Heads-up", + "headsUpDesc": "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space." + } + }, "sign": { "title": "Sign", "header": "Sign PDFs", diff --git a/frontend/src/components/tools/scannerImageSplit/ScannerImageSplitSettings.tsx b/frontend/src/components/tools/scannerImageSplit/ScannerImageSplitSettings.tsx new file mode 100644 index 000000000..e0aef436a --- /dev/null +++ b/frontend/src/components/tools/scannerImageSplit/ScannerImageSplitSettings.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { NumberInput, Stack } from '@mantine/core'; +import { ScannerImageSplitParameters } from '../../../hooks/tools/scannerImageSplit/useScannerImageSplitParameters'; + +interface ScannerImageSplitSettingsProps { + parameters: ScannerImageSplitParameters; + onParameterChange: (key: K, value: ScannerImageSplitParameters[K]) => void; + disabled?: boolean; +} + +const ScannerImageSplitSettings: React.FC = ({ + parameters, + onParameterChange, + disabled = false +}) => { + const { t } = useTranslation(); + + return ( + + onParameterChange('angle_threshold', Number(value) || 10)} + min={0} + step={1} + disabled={disabled} + /> + + onParameterChange('tolerance', Number(value) || 30)} + min={0} + step={1} + disabled={disabled} + /> + + onParameterChange('min_area', Number(value) || 10000)} + min={0} + step={100} + disabled={disabled} + /> + + onParameterChange('min_contour_area', Number(value) || 500)} + min={0} + step={10} + disabled={disabled} + /> + + onParameterChange('border_size', Number(value) || 1)} + min={0} + step={1} + disabled={disabled} + /> + + ); +}; + +export default ScannerImageSplitSettings; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useScannerImageSplitTips.ts b/frontend/src/components/tooltips/useScannerImageSplitTips.ts new file mode 100644 index 000000000..7f84aa325 --- /dev/null +++ b/frontend/src/components/tooltips/useScannerImageSplitTips.ts @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useScannerImageSplitTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t('scannerImageSplit.tooltip.title', 'Photo Splitter') + }, + tips: [ + { + title: t('scannerImageSplit.tooltip.whatThisDoes', 'What this does'), + description: t('scannerImageSplit.tooltip.whatThisDoesDesc', + 'Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.' + ) + }, + { + title: t('scannerImageSplit.tooltip.whenToUse', 'When to use'), + bullets: [ + t('scannerImageSplit.tooltip.useCase1', 'Scan whole album pages in one go'), + t('scannerImageSplit.tooltip.useCase2', 'Split flatbed batches into separate files'), + t('scannerImageSplit.tooltip.useCase3', 'Break collages into individual photos'), + t('scannerImageSplit.tooltip.useCase4', 'Pull photos from documents') + ] + }, + { + title: t('scannerImageSplit.tooltip.quickFixes', 'Quick fixes'), + bullets: [ + t('scannerImageSplit.tooltip.problem1', 'Photos not detected → increase Tolerance to 30–50'), + t('scannerImageSplit.tooltip.problem2', 'Too many false detections → increase Minimum Area to 15,000–20,000'), + t('scannerImageSplit.tooltip.problem3', 'Crops are too tight → increase Border Size to 5–10'), + t('scannerImageSplit.tooltip.problem4', 'Tilted photos not straightened → lower Angle Threshold to ~5°'), + t('scannerImageSplit.tooltip.problem5', 'Dust/noise boxes → increase Minimum Contour Area to 1000–2000') + ] + }, + { + title: t('scannerImageSplit.tooltip.setupTips', 'Setup tips'), + bullets: [ + t('scannerImageSplit.tooltip.tip1', 'Use a plain, light background'), + t('scannerImageSplit.tooltip.tip2', 'Leave a small gap (≈1 cm) between photos'), + t('scannerImageSplit.tooltip.tip3', 'Scan at 300–600 DPI'), + t('scannerImageSplit.tooltip.tip4', 'Clean the scanner glass') + ] + }, + { + title: t('scannerImageSplit.tooltip.headsUp', 'Heads-up'), + description: t('scannerImageSplit.tooltip.headsUpDesc', + 'Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy—try a lighter or darker background and leave more space.' + ) + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 5400b5e98..5cd9970b3 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -68,10 +68,13 @@ import RedactSingleStepSettings from "../components/tools/redact/RedactSingleSte import RotateSettings from "../components/tools/rotate/RotateSettings"; import Redact from "../tools/Redact"; import AdjustPageScale from "../tools/AdjustPageScale"; +import ScannerImageSplit from "../tools/ScannerImageSplit"; import { ToolId } from "../types/toolId"; import MergeSettings from '../components/tools/merge/MergeSettings'; import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation"; +import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation"; import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings"; +import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings"; import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep"; import CropSettings from "../components/tools/crop/CropSettings"; @@ -624,10 +627,14 @@ export function useFlatToolRegistry(): ToolRegistry { scannerImageSplit: { icon: , name: t("home.scannerImageSplit.title", "Detect & Split Scanned Photos"), - component: null, + component: ScannerImageSplit, description: t("home.scannerImageSplit.desc", "Detect and split scanned photos into separate pages"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, + maxFiles: -1, + endpoints: ["extract-image-scans"], + operationConfig: scannerImageSplitOperationConfig, + settingsComponent: ScannerImageSplitSettings, synonyms: getSynonyms(t, "ScannerImageSplit"), }, overlayPdfs: { diff --git a/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts b/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts new file mode 100644 index 000000000..4ef0f99f6 --- /dev/null +++ b/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters'; +import { zipFileService } from '../../../services/zipFileService'; + +export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('angle_threshold', parameters.angle_threshold.toString()); + formData.append('tolerance', parameters.tolerance.toString()); + formData.append('min_area', parameters.min_area.toString()); + formData.append('min_contour_area', parameters.min_contour_area.toString()); + formData.append('border_size', parameters.border_size.toString()); + return formData; +}; + +// Custom response handler to handle ZIP files that might be misidentified +const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise => { + try { + // Always try to extract as ZIP first, regardless of content-type + const extractionResult = await zipFileService.extractAllFiles(responseData); + if (extractionResult.success && extractionResult.extractedFiles.length > 0) { + return extractionResult.extractedFiles; + } + } catch (error) { + console.warn('Failed to extract as ZIP, treating as single file:', error); + } + + // Fallback: treat as single file (PNG image) + const inputFileName = inputFiles[0]?.name || 'document'; + const baseFileName = inputFileName.replace(/\.[^.]+$/, ''); + const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' }); + return [singleFile]; +}; + +export const scannerImageSplitOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildScannerImageSplitFormData, + operationType: 'scannerImageSplit', + endpoint: '/api/v1/misc/extract-image-scans', + multiFileEndpoint: false, + responseHandler: scannerImageSplitResponseHandler, + defaultParameters, +} as const; + +export const useScannerImageSplitOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...scannerImageSplitOperationConfig, + getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitParameters.ts b/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitParameters.ts new file mode 100644 index 000000000..bb028c0e7 --- /dev/null +++ b/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitParameters.ts @@ -0,0 +1,31 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface ScannerImageSplitParameters extends BaseParameters { + angle_threshold: number; + tolerance: number; + min_area: number; + min_contour_area: number; + border_size: number; +} + +export const defaultParameters: ScannerImageSplitParameters = { + angle_threshold: 10, + tolerance: 30, + min_area: 10000, + min_contour_area: 500, + border_size: 1, +}; + +export type ScannerImageSplitParametersHook = BaseParametersHook; + +export const useScannerImageSplitParameters = (): ScannerImageSplitParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'extract-image-scans', + validateFn: () => { + // All parameters are numeric with defaults, validation handled by form + return true; + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/services/zipFileService.ts b/frontend/src/services/zipFileService.ts index 872157d51..daca80e51 100644 --- a/frontend/src/services/zipFileService.ts +++ b/frontend/src/services/zipFileService.ts @@ -338,6 +338,125 @@ export class ZipFileService { return errorMessage.includes('password') || errorMessage.includes('encrypted'); } } + + /** + * Extract all files from a ZIP archive (not limited to PDFs) + */ + async extractAllFiles( + file: File | Blob, + onProgress?: (progress: ZipExtractionProgress) => void + ): Promise { + const result: ZipExtractionResult = { + success: false, + extractedFiles: [], + errors: [], + totalFiles: 0, + extractedCount: 0 + }; + + try { + // Load ZIP contents + const zip = new JSZip(); + const zipContents = await zip.loadAsync(file); + + // Get all files (not directories) + const allFiles = Object.entries(zipContents.files).filter(([, zipEntry]) => + !zipEntry.dir + ); + + result.totalFiles = allFiles.length; + + // Extract each file + for (let i = 0; i < allFiles.length; i++) { + const [filename, zipEntry] = allFiles[i]; + + try { + // Report progress + if (onProgress) { + onProgress({ + currentFile: filename, + extractedCount: i, + totalFiles: allFiles.length, + progress: (i / allFiles.length) * 100 + }); + } + + // Extract file content + const content = await zipEntry.async('blob'); + + // Create File object with appropriate MIME type + const mimeType = this.getMimeTypeFromExtension(filename); + const extractedFile = new File([content], filename, { type: mimeType }); + + result.extractedFiles.push(extractedFile); + result.extractedCount++; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Failed to extract "${filename}": ${errorMessage}`); + } + } + + // Final progress report + if (onProgress) { + onProgress({ + currentFile: '', + extractedCount: result.extractedCount, + totalFiles: result.totalFiles, + progress: 100 + }); + } + + result.success = result.extractedFiles.length > 0; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Failed to process ZIP file: ${errorMessage}`); + } + + return result; + } + + /** + * Get MIME type based on file extension + */ + private getMimeTypeFromExtension(fileName: string): string { + const ext = fileName.toLowerCase().split('.').pop(); + + const mimeTypes: Record = { + // Images + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'bmp': 'image/bmp', + 'svg': 'image/svg+xml', + 'tiff': 'image/tiff', + 'tif': 'image/tiff', + + // Documents + 'pdf': 'application/pdf', + 'txt': 'text/plain', + 'html': 'text/html', + 'css': 'text/css', + 'js': 'application/javascript', + 'json': 'application/json', + 'xml': 'application/xml', + + // Office documents + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + + // Archives + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + }; + + return mimeTypes[ext || ''] || 'application/octet-stream'; + } } // Export singleton instance diff --git a/frontend/src/tools/ScannerImageSplit.tsx b/frontend/src/tools/ScannerImageSplit.tsx new file mode 100644 index 000000000..2b0a12ff5 --- /dev/null +++ b/frontend/src/tools/ScannerImageSplit.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings"; +import { useScannerImageSplitParameters } from "../hooks/tools/scannerImageSplit/useScannerImageSplitParameters"; +import { useScannerImageSplitOperation } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useScannerImageSplitTips } from "../components/tooltips/useScannerImageSplitTips"; + +const ScannerImageSplit = (props: BaseToolProps) => { + const { t } = useTranslation(); + const scannerImageSplitTips = useScannerImageSplitTips(); + + const base = useBaseTool( + 'scannerImageSplit', + useScannerImageSplitParameters, + useScannerImageSplitOperation, + props + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: "Settings", + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: scannerImageSplitTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("scannerImageSplit.submit", "Extract Image Scans"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("scannerImageSplit.title", "Extracted Images"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default ScannerImageSplit as ToolComponent; \ No newline at end of file From 18fa16f08ea7769bc588fd222b1a93fa60ed05a8 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:44:25 +0100 Subject: [PATCH 2/6] Invert colors (#4498) --- .../public/locales/en-GB/translation.json | 60 +++++++--- .../replaceColor/ReplaceColorSettings.tsx | 108 ++++++++++++++++++ .../tooltips/useReplaceColorTips.ts | 40 +++++++ .../src/data/useTranslatedToolRegistry.tsx | 17 ++- .../replaceColor/useReplaceColorOperation.ts | 38 ++++++ .../replaceColor/useReplaceColorParameters.ts | 29 +++++ frontend/src/tools/ReplaceColor.tsx | 58 ++++++++++ frontend/src/types/toolId.ts | 2 +- frontend/src/utils/urlMapping.ts | 4 +- 9 files changed, 329 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/tools/replaceColor/ReplaceColorSettings.tsx create mode 100644 frontend/src/components/tooltips/useReplaceColorTips.ts create mode 100644 frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts create mode 100644 frontend/src/hooks/tools/replaceColor/useReplaceColorParameters.ts create mode 100644 frontend/src/tools/ReplaceColor.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 17bafc6f4..c737b85a5 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -619,8 +619,7 @@ "title": "Auto Split by Size/Count", "desc": "Automatically split PDFs by file size or page count" }, - "replaceColorPdf": { - "tags": "color,replace,invert", + "replaceColor": { "title": "Replace & Invert Colour", "desc": "Replace or invert colours in PDF documents" }, @@ -2533,25 +2532,48 @@ }, "selectCustomCert": "Custom Certificate File X.509 (Optional)" }, - "replace-color": { - "title": "Advanced Colour options", - "header": "Replace-Invert Colour PDF", - "selectText": { - "1": "Replace or Invert colour Options", - "2": "Default(Default high contrast colours)", - "3": "Custom(Customised colours)", - "4": "Full-Invert(Invert all colours)", - "5": "High contrast colour options", - "6": "white text on black background", - "7": "Black text on white background", - "8": "Yellow text on black background", - "9": "Green text on black background", - "10": "Choose text Colour", - "11": "Choose background Colour" + "replaceColor": { + "labels": { + "settings": "Settings", + "colourOperation": "Colour operation" }, - "submit": "Replace" + "options": { + "highContrast": "High contrast", + "invertAll": "Invert all colours", + "custom": "Custom" + }, + "tooltip": { + "header": { + "title": "Replace & Invert Colour Settings Overview" + }, + "description": { + "title": "Description", + "text": "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes." + }, + "highContrast": { + "title": "High Contrast", + "text": "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance.", + "bullet1": "White text on black background - Classic dark mode", + "bullet2": "Black text on white background - Standard high contrast", + "bullet3": "Yellow text on black background - High visibility option", + "bullet4": "Green text on black background - Alternative high contrast" + }, + "invertAll": { + "title": "Invert All Colours", + "text": "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions." + }, + "custom": { + "title": "Custom Colours", + "text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.", + "bullet1": "Text colour - Choose the colour for text elements", + "bullet2": "Background colour - Set the background colour for the document" + } + }, + "error": { + "failed": "An error occurred while processing the colour replacement." + } }, - "replaceColorPdf": { + "replaceColor": { "tags": "Replace Colour,Page operations,Back end,server side" }, "login": { diff --git a/frontend/src/components/tools/replaceColor/ReplaceColorSettings.tsx b/frontend/src/components/tools/replaceColor/ReplaceColorSettings.tsx new file mode 100644 index 000000000..8630d1811 --- /dev/null +++ b/frontend/src/components/tools/replaceColor/ReplaceColorSettings.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { Stack, Text, Select, ColorInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ReplaceColorParameters } from "../../../hooks/tools/replaceColor/useReplaceColorParameters"; + +interface ReplaceColorSettingsProps { + parameters: ReplaceColorParameters; + onParameterChange: (key: K, value: ReplaceColorParameters[K]) => void; + disabled?: boolean; +} + +const ReplaceColorSettings = ({ parameters, onParameterChange, disabled = false }: ReplaceColorSettingsProps) => { + const { t } = useTranslation(); + + const replaceAndInvertOptions = [ + { + value: 'HIGH_CONTRAST_COLOR', + label: t('replaceColor.options.highContrast', 'High contrast') + }, + { + value: 'FULL_INVERSION', + label: t('replaceColor.options.invertAll', 'Invert all colours') + }, + { + value: 'CUSTOM_COLOR', + label: t('replaceColor.options.custom', 'Custom') + } + ]; + + const highContrastOptions = [ + { + value: 'WHITE_TEXT_ON_BLACK', + label: t('replace-color.selectText.6', 'White text on black background') + }, + { + value: 'BLACK_TEXT_ON_WHITE', + label: t('replace-color.selectText.7', 'Black text on white background') + }, + { + value: 'YELLOW_TEXT_ON_BLACK', + label: t('replace-color.selectText.8', 'Yellow text on black background') + }, + { + value: 'GREEN_TEXT_ON_BLACK', + label: t('replace-color.selectText.9', 'Green text on black background') + } + ]; + + return ( + + + + {t('replaceColor.labels.colourOperation', 'Colour operation')} + + value && onParameterChange('highContrastColorCombination', value as ReplaceColorParameters['highContrastColorCombination'])} + data={highContrastOptions} + disabled={disabled} + /> + + )} + + {parameters.replaceAndInvertOption === 'CUSTOM_COLOR' && ( + <> + + + {t('replace-color.selectText.10', 'Choose text Color')} + + onParameterChange('textColor', value)} + format="hex" + disabled={disabled} + /> + + + + + {t('replace-color.selectText.11', 'Choose background Color')} + + onParameterChange('backGroundColor', value)} + format="hex" + disabled={disabled} + /> + + + )} + + ); +}; + +export default ReplaceColorSettings; \ No newline at end of file diff --git a/frontend/src/components/tooltips/useReplaceColorTips.ts b/frontend/src/components/tooltips/useReplaceColorTips.ts new file mode 100644 index 000000000..58cc5f9d5 --- /dev/null +++ b/frontend/src/components/tooltips/useReplaceColorTips.ts @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useReplaceColorTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("replaceColor.tooltip.header.title", "Replace & Invert Colour Settings Overview") + }, + tips: [ + { + title: t("replaceColor.tooltip.description.title", "Description"), + description: t("replaceColor.tooltip.description.text", "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes.") + }, + { + title: t("replaceColor.tooltip.highContrast.title", "High Contrast"), + description: t("replaceColor.tooltip.highContrast.text", "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance."), + bullets: [ + t("replaceColor.tooltip.highContrast.bullet1", "White text on black background - Classic dark mode"), + t("replaceColor.tooltip.highContrast.bullet2", "Black text on white background - Standard high contrast"), + t("replaceColor.tooltip.highContrast.bullet3", "Yellow text on black background - High visibility option"), + t("replaceColor.tooltip.highContrast.bullet4", "Green text on black background - Alternative high contrast") + ] + }, + { + title: t("replaceColor.tooltip.invertAll.title", "Invert All Colours"), + description: t("replaceColor.tooltip.invertAll.text", "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions.") + }, + { + title: t("replaceColor.tooltip.custom.title", "Custom Colours"), + description: t("replaceColor.tooltip.custom.text", "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements."), + bullets: [ + t("replaceColor.tooltip.custom.bullet1", "Text colour - Choose the colour for text elements"), + t("replaceColor.tooltip.custom.bullet2", "Background colour - Set the background colour for the document") + ] + } + ] + }; +}; \ No newline at end of file diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 5cd9970b3..f2bc2b197 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -50,6 +50,7 @@ import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation" import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation"; import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation"; import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation"; +import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation"; import CompressSettings from "../components/tools/compress/CompressSettings"; import SplitSettings from "../components/tools/split/SplitSettings"; import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; @@ -68,6 +69,7 @@ import RedactSingleStepSettings from "../components/tools/redact/RedactSingleSte import RotateSettings from "../components/tools/rotate/RotateSettings"; import Redact from "../tools/Redact"; import AdjustPageScale from "../tools/AdjustPageScale"; +import ReplaceColor from "../tools/ReplaceColor"; import ScannerImageSplit from "../tools/ScannerImageSplit"; import { ToolId } from "../types/toolId"; import MergeSettings from '../components/tools/merge/MergeSettings'; @@ -77,6 +79,7 @@ import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustP import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings"; import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep"; import CropSettings from "../components/tools/crop/CropSettings"; +import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings"; const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI @@ -646,14 +649,18 @@ export function useFlatToolRegistry(): ToolRegistry { subcategoryId: SubcategoryId.ADVANCED_FORMATTING, synonyms: getSynonyms(t, "overlayPdfs"), }, - replaceColorPdf: { + replaceColor: { icon: , - name: t("home.replaceColorPdf.title", "Replace & Invert Color"), - component: null, - description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"), + name: t("home.replaceColor.title", "Replace & Invert Color"), + component: ReplaceColor, + description: t("home.replaceColor.desc", "Replace or invert colors in PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.ADVANCED_FORMATTING, - synonyms: getSynonyms(t, "replaceColorPdf"), + maxFiles: -1, + endpoints: ["replace-invert-pdf"], + operationConfig: replaceColorOperationConfig, + settingsComponent: ReplaceColorSettings, + synonyms: getSynonyms(t, "replaceColor"), }, addImage: { icon: , diff --git a/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts b/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts new file mode 100644 index 000000000..e2a101a1c --- /dev/null +++ b/frontend/src/hooks/tools/replaceColor/useReplaceColorOperation.ts @@ -0,0 +1,38 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { ReplaceColorParameters, defaultParameters } from './useReplaceColorParameters'; + +export const buildReplaceColorFormData = (parameters: ReplaceColorParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + + formData.append('replaceAndInvertOption', parameters.replaceAndInvertOption); + + if (parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR') { + formData.append('highContrastColorCombination', parameters.highContrastColorCombination); + } else if (parameters.replaceAndInvertOption === 'CUSTOM_COLOR') { + formData.append('textColor', parameters.textColor); + formData.append('backGroundColor', parameters.backGroundColor); + } + + return formData; +}; + +export const replaceColorOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildReplaceColorFormData, + operationType: 'replaceColor', + endpoint: '/api/v1/misc/replace-invert-pdf', + multiFileEndpoint: false, + defaultParameters, +} as const; + +export const useReplaceColorOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...replaceColorOperationConfig, + getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the color replacement.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/replaceColor/useReplaceColorParameters.ts b/frontend/src/hooks/tools/replaceColor/useReplaceColorParameters.ts new file mode 100644 index 000000000..2b269aa1a --- /dev/null +++ b/frontend/src/hooks/tools/replaceColor/useReplaceColorParameters.ts @@ -0,0 +1,29 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface ReplaceColorParameters extends BaseParameters { + replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION'; + highContrastColorCombination: 'WHITE_TEXT_ON_BLACK' | 'BLACK_TEXT_ON_WHITE' | 'YELLOW_TEXT_ON_BLACK' | 'GREEN_TEXT_ON_BLACK'; + textColor: string; + backGroundColor: string; +} + +export const defaultParameters: ReplaceColorParameters = { + replaceAndInvertOption: 'HIGH_CONTRAST_COLOR', + highContrastColorCombination: 'WHITE_TEXT_ON_BLACK', + textColor: '#000000', + backGroundColor: '#ffffff', +}; + +export type ReplaceColorParametersHook = BaseParametersHook; + +export const useReplaceColorParameters = (): ReplaceColorParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'replace-invert-pdf', + validateFn: () => { + // All parameters are always valid as they have defaults + return true; + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/tools/ReplaceColor.tsx b/frontend/src/tools/ReplaceColor.tsx new file mode 100644 index 000000000..5486fc591 --- /dev/null +++ b/frontend/src/tools/ReplaceColor.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings"; +import { useReplaceColorParameters } from "../hooks/tools/replaceColor/useReplaceColorParameters"; +import { useReplaceColorOperation } from "../hooks/tools/replaceColor/useReplaceColorOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; +import { useReplaceColorTips } from "../components/tooltips/useReplaceColorTips"; + +const ReplaceColor = (props: BaseToolProps) => { + const { t } = useTranslation(); + const replaceColorTips = useReplaceColorTips(); + + const base = useBaseTool( + 'replaceColor', + useReplaceColorParameters, + useReplaceColorOperation, + props + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t("replaceColor.labels.settings", "Settings"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: replaceColorTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("replace-color.submit", "Replace"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("replace-color.title", "Replace-Invert-Color"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default ReplaceColor as ToolComponent; \ No newline at end of file diff --git a/frontend/src/types/toolId.ts b/frontend/src/types/toolId.ts index 49bdb6523..bdc614c6f 100644 --- a/frontend/src/types/toolId.ts +++ b/frontend/src/types/toolId.ts @@ -49,7 +49,7 @@ export const TOOL_IDS = [ 'validateSignature', 'read', 'automate', - 'replaceColorPdf', + 'replaceColor', 'showJS', 'devApi', 'devFolderScanning', diff --git a/frontend/src/utils/urlMapping.ts b/frontend/src/utils/urlMapping.ts index 016591ab3..409eaea3f 100644 --- a/frontend/src/utils/urlMapping.ts +++ b/frontend/src/utils/urlMapping.ts @@ -76,7 +76,7 @@ export const URL_TO_TOOL_MAP: Record = { '/extract-images': 'extractImages', '/adjust-contrast': 'adjustContrast', '/fake-scan': 'fakeScan', - '/replace-color-pdf': 'replaceColorPdf', + '/replace-color-pdf': 'replaceColor', // Metadata and info '/change-metadata': 'changeMetadata', @@ -116,7 +116,7 @@ export const URL_TO_TOOL_MAP: Record = { '/view-pdf': 'read', '/get-info-on-pdf': 'getPdfInfo', '/remove-image-pdf': 'removeImage', - '/replace-and-invert-color-pdf': 'replaceColorPdf', + '/replace-and-invert-color-pdf': 'replaceColor', '/pipeline': 'automate', '/extract-image-scans': 'scannerImageSplit', '/show-javascript': 'showJS', From 9758e871d4da5fdffb8fd2df78b36bb2f73e1195 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:45:15 +0100 Subject: [PATCH 3/6] feat: Add React-based extract-images tool (#4501) --- .../public/locales/en-GB/translation.json | 8 ++- .../public/locales/en-US/translation.json | 8 ++- .../extractImages/ExtractImagesSettings.tsx | 46 ++++++++++++++++ .../src/data/useTranslatedToolRegistry.tsx | 11 +++- .../useExtractImagesOperation.ts | 51 +++++++++++++++++ .../useExtractImagesParameters.ts | 19 +++++++ frontend/src/tools/ExtractImages.tsx | 55 +++++++++++++++++++ 7 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/tools/extractImages/ExtractImagesSettings.tsx create mode 100644 frontend/src/hooks/tools/extractImages/useExtractImagesOperation.ts create mode 100644 frontend/src/hooks/tools/extractImages/useExtractImagesParameters.ts create mode 100644 frontend/src/tools/ExtractImages.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c737b85a5..9a976c3af 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1595,7 +1595,13 @@ "header": "Extract Images", "selectText": "Select image format to convert extracted images to", "allowDuplicates": "Save duplicate images", - "submit": "Extract" + "submit": "Extract", + "settings": { + "title": "Settings" + }, + "error": { + "failed": "An error occurred while extracting images from the PDF." + } }, "pdfToPDFA": { "tags": "archive,long-term,standard,conversion,storage,preservation", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index ae23ddd73..f12e54fbf 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -1056,7 +1056,13 @@ "header": "Extract Images", "selectText": "Select image format to convert extracted images to", "allowDuplicates": "Save duplicate images", - "submit": "Extract" + "submit": "Extract", + "settings": { + "title": "Settings" + }, + "error": { + "failed": "An error occurred while extracting images from the PDF." + } }, "pdfToPDFA": { "tags": "archive,long-term,standard,conversion,storage,preservation", diff --git a/frontend/src/components/tools/extractImages/ExtractImagesSettings.tsx b/frontend/src/components/tools/extractImages/ExtractImagesSettings.tsx new file mode 100644 index 000000000..2dde8b53a --- /dev/null +++ b/frontend/src/components/tools/extractImages/ExtractImagesSettings.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next'; +import { Stack, Select, Checkbox } from '@mantine/core'; +import { ExtractImagesParameters } from '../../../hooks/tools/extractImages/useExtractImagesParameters'; + +interface ExtractImagesSettingsProps { + parameters: ExtractImagesParameters; + onParameterChange: (key: K, value: ExtractImagesParameters[K]) => void; + disabled?: boolean; +} + +const ExtractImagesSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ExtractImagesSettingsProps) => { + const { t } = useTranslation(); + + return ( + + { + const files = Array.from(e.target.files || []); + // Append to existing attachments instead of replacing + const newAttachments = [...params.parameters.attachments, ...files]; + params.updateParameter('attachments', newAttachments); + // Reset the input so the same file can be selected again + e.target.value = ''; + }} + disabled={endpointLoading} + style={{ display: 'none' }} + id="attachments-input" + /> + + + + {params.parameters.attachments && params.parameters.attachments.length > 0 && ( + + + {t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({params.parameters.attachments.length}) + + + + {params.parameters.attachments.map((file, index) => ( + + + {/* Filename (two-line clamp, wraps, no icon on the left) */} +
+
+ {file.name} +
+
+ + ({(file.size / 1024).toFixed(1)} KB) + +
+ { + const newAttachments = params.parameters.attachments.filter((_, i) => i !== index); + params.updateParameter('attachments', newAttachments); + }} + > + + +
+ ))} +
+
+
+ )} + + ), + }); + + return steps; + }; + + return createToolFlow({ + files: { + selectedFiles, + isCollapsed: hasResults, + }, + steps: getSteps(), + executeButton: { + text: t('AddAttachmentsRequest.submit', 'Add Attachments'), + isVisible: !hasResults, + loadingText: t('loading'), + onClick: handleExecute, + disabled: !params.validateParameters() || !hasFiles || !endpointEnabled, + }, + review: { + isVisible: hasResults, + operation: operation, + title: t('AddAttachmentsRequest.results.title', 'Attachment Results'), + onFileClick: (file) => onPreviewFile?.(file), + onUndo: async () => { + await operation.undoOperation(); + onPreviewFile?.(null); + }, + }, + }); +}; + +AddAttachments.tool = () => useAddAttachmentsOperation; + +export default AddAttachments as ToolComponent; From f2a6e95fcfd50755b0e060f57ddddb4b5e667755 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:46:02 +0100 Subject: [PATCH 5/6] Feature/remove images (#4503) --- .../public/locales/en-US/translation.json | 14 ++++-- .../src/data/useTranslatedToolRegistry.tsx | 10 +++-- .../removeImage/useRemoveImageOperation.ts | 30 +++++++++++++ .../removeImage/useRemoveImageParameters.ts | 17 +++++++ frontend/src/tools/RemoveImage.tsx | 45 +++++++++++++++++++ 5 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 frontend/src/hooks/tools/removeImage/useRemoveImageOperation.ts create mode 100644 frontend/src/hooks/tools/removeImage/useRemoveImageParameters.ts create mode 100644 frontend/src/tools/RemoveImage.tsx diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 5bdde8cb2..331a76511 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -1810,10 +1810,16 @@ } }, "removeImage": { - "title": "Remove image", - "header": "Remove image", - "removeImage": "Remove image", - "submit": "Remove image" + "title": "Remove Images", + "header": "Remove Images", + "removeImage": "Remove Images", + "submit": "Remove Images", + "results": { + "title": "Remove Images Results" + }, + "error": { + "failed": "Failed to remove images from the PDF." + } }, "splitByChapters": { "title": "Split PDF by Chapters", diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 97aa9725c..c23fe3628 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -22,6 +22,7 @@ import AutoRename from "../tools/AutoRename"; import SingleLargePage from "../tools/SingleLargePage"; import UnlockPdfForms from "../tools/UnlockPdfForms"; import RemoveCertificateSign from "../tools/RemoveCertificateSign"; +import RemoveImage from "../tools/RemoveImage"; import CertSign from "../tools/CertSign"; import BookletImposition from "../tools/BookletImposition"; import Flatten from "../tools/Flatten"; @@ -533,11 +534,14 @@ export function useFlatToolRegistry(): ToolRegistry { }, removeImage: { icon: , - name: t("home.removeImage.title", "Remove Image"), - component: null, - description: t("home.removeImage.desc", "Remove images from PDF documents"), + name: t("home.removeImage.title", "Remove Images"), + component: RemoveImage, + description: t("home.removeImage.desc", "Remove all images from a PDF document"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, + maxFiles: -1, + endpoints: ["remove-image-pdf"], + operationConfig: undefined, synonyms: getSynonyms(t, "removeImage"), }, removePassword: { diff --git a/frontend/src/hooks/tools/removeImage/useRemoveImageOperation.ts b/frontend/src/hooks/tools/removeImage/useRemoveImageOperation.ts new file mode 100644 index 000000000..0f6649598 --- /dev/null +++ b/frontend/src/hooks/tools/removeImage/useRemoveImageOperation.ts @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import type { RemoveImageParameters } from './useRemoveImageParameters'; + +export const buildRemoveImageFormData = (_params: RemoveImageParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + return formData; +}; + +export const removeImageOperationConfig: ToolOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRemoveImageFormData, + operationType: 'removeImage', + endpoint: '/api/v1/general/remove-image-pdf', +}; + +export const useRemoveImageOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...removeImageOperationConfig, + getErrorMessage: createStandardErrorHandler( + t('removeImage.error.failed', 'Failed to remove images from the PDF.') + ), + }); +}; + + diff --git a/frontend/src/hooks/tools/removeImage/useRemoveImageParameters.ts b/frontend/src/hooks/tools/removeImage/useRemoveImageParameters.ts new file mode 100644 index 000000000..f57a9e93e --- /dev/null +++ b/frontend/src/hooks/tools/removeImage/useRemoveImageParameters.ts @@ -0,0 +1,17 @@ +import { useBaseParameters } from '../shared/useBaseParameters'; +import type { BaseParametersHook } from '../shared/useBaseParameters'; + +export type RemoveImageParameters = Record; + +export const defaultParameters: RemoveImageParameters = {}; + +export type RemoveImageParametersHook = BaseParametersHook; + +export const useRemoveImageParameters = (): RemoveImageParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'remove-image-pdf', + }); +}; + + diff --git a/frontend/src/tools/RemoveImage.tsx b/frontend/src/tools/RemoveImage.tsx new file mode 100644 index 000000000..f636f9f65 --- /dev/null +++ b/frontend/src/tools/RemoveImage.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import { useRemoveImageParameters } from "../hooks/tools/removeImage/useRemoveImageParameters"; +import { useRemoveImageOperation } from "../hooks/tools/removeImage/useRemoveImageOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const RemoveImage = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'removeImage', + useRemoveImageParameters, + useRemoveImageOperation, + props + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [], + executeButton: { + text: t("removeImage.submit", "Remove Images"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("removeImage.results.title", "Remove Images Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +RemoveImage.tool = () => useRemoveImageOperation; + +export default RemoveImage as ToolComponent; + + From 0bdc6466ca99e1cdbddd4e3c7794a2f92dffc613 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:49:18 +0100 Subject: [PATCH 6/6] add the reorganize pages tool (#4506) --- .../public/locales/en-GB/translation.json | 15 ++- .../public/locales/en-US/translation.json | 19 +++- .../ReorganizePagesSettings.tsx | 65 +++++++++++ .../tools/reorganizePages/constants.ts | 59 ++++++++++ .../src/data/useTranslatedToolRegistry.tsx | 7 +- .../useReorganizePagesOperation.ts | 36 ++++++ .../useReorganizePagesParameters.ts | 39 +++++++ frontend/src/tools/ReorganizePages.tsx | 104 ++++++++++++++++++ 8 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/tools/reorganizePages/ReorganizePagesSettings.tsx create mode 100644 frontend/src/components/tools/reorganizePages/constants.ts create mode 100644 frontend/src/hooks/tools/reorganizePages/useReorganizePagesOperation.ts create mode 100644 frontend/src/hooks/tools/reorganizePages/useReorganizePagesParameters.ts create mode 100644 frontend/src/tools/ReorganizePages.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 8c0c6497d..89ace5ea2 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -958,7 +958,7 @@ "header": "PDF Page Organiser", "submit": "Rearrange Pages", "mode": { - "_value": "Mode", + "_value": "Organization mode", "1": "Custom Page Order", "2": "Reverse Order", "3": "Duplex Sort", @@ -971,6 +971,19 @@ "10": "Odd-Even Merge", "11": "Duplicate all pages" }, + "desc": { + "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.", + "REVERSE_ORDER": "Flip the document so the last page becomes first and so on.", + "DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).", + "BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).", + "SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimised for binding on the side).", + "ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.", + "ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.", + "DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).", + "REMOVE_FIRST": "Remove the first page from the document.", + "REMOVE_LAST": "Remove the last page from the document.", + "REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document." + }, "placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)" }, "addImage": { diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 331a76511..9cd801a66 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -769,7 +769,7 @@ "header": "PDF Page Organizer", "submit": "Rearrange Pages", "mode": { - "_value": "Mode", + "_value": "Organization mode", "1": "Custom Page Order", "2": "Reverse Order", "3": "Duplex Sort", @@ -782,6 +782,19 @@ "10": "Odd-Even Merge", "11": "Duplicate all pages" }, + "desc": { + "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.", + "REVERSE_ORDER": "Flip the document so the last page becomes first and so on.", + "DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).", + "BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).", + "SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side).", + "ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.", + "ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.", + "DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).", + "REMOVE_FIRST": "Remove the first page from the document.", + "REMOVE_LAST": "Remove the last page from the document.", + "REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document." + }, "placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)" }, "addImage": { @@ -1864,7 +1877,7 @@ "title": "How we use Cookies", "description": { "1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.", - "2": "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." + "2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." }, "acceptAllBtn": "Okay", "acceptNecessaryBtn": "No Thanks", @@ -1888,7 +1901,7 @@ "1": "Strictly Necessary Cookies", "2": "Always Enabled" }, - "description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off." + "description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off." }, "analytics": { "title": "Analytics", diff --git a/frontend/src/components/tools/reorganizePages/ReorganizePagesSettings.tsx b/frontend/src/components/tools/reorganizePages/ReorganizePagesSettings.tsx new file mode 100644 index 000000000..910d13f15 --- /dev/null +++ b/frontend/src/components/tools/reorganizePages/ReorganizePagesSettings.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Divider, Select, Stack, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ReorganizePagesParameters } from '../../../hooks/tools/reorganizePages/useReorganizePagesParameters'; +import { getReorganizePagesModeData } from './constants'; + +export default function ReorganizePagesSettings({ + parameters, + onParameterChange, + disabled, +}: { + parameters: ReorganizePagesParameters; + onParameterChange: ( + key: K, + value: ReorganizePagesParameters[K] + ) => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + const modeData = getReorganizePagesModeData(t); + + const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE'; + const selectedMode = modeData.find(mode => mode.value === parameters.customMode) || modeData[0]; + return ( + +