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