Convert extract-image-scans to React component

- Create full React implementation for scanner image split tool
- Add ScannerImageSplit component with settings and operation hooks
- Enhance zipFileService with extractAllFiles() method for general ZIP handling
- Add custom response handler to properly extract ZIP files containing images
- Integrate tool into registry with complete operation config
- Support both single image and ZIP responses from backend

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Anthony Stirling 2025-09-25 23:13:08 +01:00
parent fd52dc0226
commit e7109bb4e9
6 changed files with 341 additions and 1 deletions

View File

@ -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: <K extends keyof ScannerImageSplitParameters>(key: K, value: ScannerImageSplitParameters[K]) => void;
disabled?: boolean;
}
const ScannerImageSplitSettings: React.FC<ScannerImageSplitSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<NumberInput
label={t('ScannerImageSplit.selectText.1', 'Angle Threshold:')}
description={t('ScannerImageSplit.selectText.2', 'Sets the minimum absolute angle required for the image to be rotated (default: 10).')}
value={parameters.angle_threshold}
onChange={(value) => onParameterChange('angle_threshold', Number(value) || 10)}
min={0}
step={1}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.3', 'Tolerance:')}
description={t('ScannerImageSplit.selectText.4', 'Determines the range of colour variation around the estimated background colour (default: 30).')}
value={parameters.tolerance}
onChange={(value) => onParameterChange('tolerance', Number(value) || 20)}
min={0}
step={1}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.5', 'Minimum Area:')}
description={t('ScannerImageSplit.selectText.6', 'Sets the minimum area threshold for a photo (default: 10000).')}
value={parameters.min_area}
onChange={(value) => onParameterChange('min_area', Number(value) || 8000)}
min={0}
step={100}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.7', 'Minimum Contour Area:')}
description={t('ScannerImageSplit.selectText.8', 'Sets the minimum contour area threshold for a photo')}
value={parameters.min_contour_area}
onChange={(value) => onParameterChange('min_contour_area', Number(value) || 500)}
min={0}
step={10}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.9', 'Border Size:')}
description={t('ScannerImageSplit.selectText.10', 'Sets the size of the border added and removed to prevent white borders in the output (default: 1).')}
value={parameters.border_size}
onChange={(value) => onParameterChange('border_size', Number(value) || 1)}
min={0}
step={1}
disabled={disabled}
/>
</Stack>
);
};
export default ScannerImageSplitSettings;

View File

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

View File

@ -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<File[]> => {
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<ScannerImageSplitParameters>({
...scannerImageSplitOperationConfig,
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
});
};

View File

@ -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<ScannerImageSplitParameters>;
export const useScannerImageSplitParameters = (): ScannerImageSplitParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'extract-image-scans',
validateFn: () => {
// All parameters are numeric with defaults, validation handled by form
return true;
},
});
};

View File

@ -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<ZipExtractionResult> {
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<string, string> = {
// 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

View File

@ -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: (
<ScannerImageSplitSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
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;