diff --git a/frontend/src/components/tools/scannerImageSplit/ScannerImageSplitSettings.tsx b/frontend/src/components/tools/scannerImageSplit/ScannerImageSplitSettings.tsx new file mode 100644 index 000000000..0f9fa379e --- /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) || 20)} + min={0} + step={1} + disabled={disabled} + /> + + onParameterChange('min_area', Number(value) || 8000)} + 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/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..1421faf16 --- /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: 20, + min_area: 8000, + 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..33c817f70 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(([filename, 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..884a6f7ed --- /dev/null +++ b/frontend/src/tools/ScannerImageSplit.tsx @@ -0,0 +1,55 @@ +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"; + +const ScannerImageSplit = (props: BaseToolProps) => { + const { t } = useTranslation(); + + 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, + 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