V2: Convert Tool (#3828)

🔄 Dynamic Processing Strategies

- Adaptive routing: Same tool uses different backend endpoints based on
file analysis
- Combined vs separate processing: Intelligently chooses between merge
operations and individual file processing
- Cross-format workflows: Enable complex conversions like "mixed files →
PDF" that other tools can't handle

  ⚙️ Format-Specific Intelligence

  Each conversion type gets tailored options:
  - HTML/ZIP → PDF: Zoom controls (0.1-3.0 increments) with live preview
  - Email → PDF: Attachment handling, size limits, recipient control
  - PDF → PDF/A: Digital signature detection with warnings
  - Images → PDF: Smart combining vs individual file options

 File Architecture

  Core Implementation:
  ├── Convert.tsx                     # Main stepped workflow UI
├── ConvertSettings.tsx # Centralized settings with smart detection
├── GroupedFormatDropdown.tsx # Enhanced format selector with grouping
├── useConvertParameters.ts # Smart detection & parameter management
  ├── useConvertOperation.ts         # Multi-strategy processing logic
  └── Settings Components:
      ├── ConvertFromWebSettings.tsx      # HTML zoom controls
      ├── ConvertFromEmailSettings.tsx    # Email attachment options
├── ConvertToPdfaSettings.tsx # PDF/A with signature detection
      ├── ConvertFromImageSettings.tsx    # Image PDF options
      └── ConvertToImageSettings.tsx      # PDF to image options

 Utility Layer

  Utils & Services:
├── convertUtils.ts # Format detection & endpoint routing
  ├── fileResponseUtils.ts          # Generic API response handling
└── setupTests.ts # Enhanced test environment with crypto mocks

  Testing & Quality

  Comprehensive Test Coverage

  Test Suite:
├── useConvertParameters.test.ts # Parameter logic & smart detection
  ├── useConvertParametersAutoDetection.test.ts  # File type analysis
├── ConvertIntegration.test.tsx # End-to-end conversion workflows
  ├── ConvertSmartDetectionIntegration.test.tsx  # Mixed file scenarios
  ├── ConvertE2E.spec.ts                     # Playwright browser tests
├── convertUtils.test.ts # Utility function validation
  └── fileResponseUtils.test.ts              # API response handling

  Advanced Test Features

  - Crypto API mocking: Proper test environment for file hashing
  - File.arrayBuffer() polyfills: Complete browser API simulation
  - Multi-file scenario testing: Complex batch processing validation
- CI/CD integration: Vitest runs in GitHub Actions with proper artifacts

---------

Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
ConnorYoh
2025-08-01 16:08:04 +01:00
committed by GitHub
co-authored by Connor Yoh Anthony Stirling
parent 8881f19b03
commit 9c9acbfb5b
68 changed files with 9173 additions and 87 deletions
@@ -0,0 +1,425 @@
import { useCallback, useState, useEffect } from 'react';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext';
import { FileOperation } from '../../../types/fileContext';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { ConvertParameters } from './useConvertParameters';
import { detectFileExtension } from '../../../utils/fileUtils';
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
export interface ConvertOperationHook {
executeOperation: (
parameters: ConvertParameters,
selectedFiles: File[]
) => Promise<void>;
// Flattened result properties for cleaner access
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
downloadUrl: string | null;
downloadFilename: string;
status: string;
errorMessage: string | null;
isLoading: boolean;
// Result management functions
resetResults: () => void;
clearError: () => void;
}
const shouldProcessFilesSeparately = (
selectedFiles: File[],
parameters: ConvertParameters
): boolean => {
return selectedFiles.length > 1 && (
// Image to PDF with combineImages = false
((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') &&
parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) ||
// PDF to image conversions (each PDF should generate its own image file)
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
// PDF to PDF/A conversions (each PDF should be processed separately)
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
// Web files to PDF conversions (each web file should generate its own PDF)
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
parameters.toExtension === 'pdf') ||
// Web files smart detection
(parameters.isSmartDetection && parameters.smartDetectionType === 'web') ||
// Mixed file types (smart detection)
(parameters.isSmartDetection && parameters.smartDetectionType === 'mixed')
);
};
const createFileFromResponse = (
responseData: any,
headers: any,
originalFileName: string,
targetExtension: string
): File => {
const originalName = originalFileName.split('.')[0];
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
return createFileFromApiResponse(responseData, headers, fallbackFilename);
};
const generateThumbnailsForFiles = async (files: File[]): Promise<string[]> => {
const thumbnails: string[] = [];
for (const file of files) {
try {
const thumbnail = await generateThumbnailForFile(file);
thumbnails.push(thumbnail);
} catch (error) {
thumbnails.push('');
}
}
return thumbnails;
};
const createDownloadInfo = async (files: File[]): Promise<{ url: string; filename: string }> => {
if (files.length === 1) {
const url = window.URL.createObjectURL(files[0]);
return { url, filename: files[0].name };
} else {
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
files.forEach(file => {
zip.file(file.name, file);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipUrl = window.URL.createObjectURL(zipBlob);
return { url: zipUrl, filename: 'converted_files.zip' };
}
};
export const useConvertOperation = (): ConvertOperationHook => {
const { t } = useTranslation();
const {
recordOperation,
markOperationApplied,
markOperationFailed,
addFiles
} = useFileContext();
const [files, setFiles] = useState<File[]>([]);
const [thumbnails, setThumbnails] = useState<string[]>([]);
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const buildFormData = useCallback((
parameters: ConvertParameters,
selectedFiles: File[]
) => {
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append("fileInput", file);
});
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters;
if (isImageFormat(toExtension)) {
formData.append("imageFormat", toExtension);
formData.append("colorType", imageOptions.colorType);
formData.append("dpi", imageOptions.dpi.toString());
formData.append("singleOrMultiple", imageOptions.singleOrMultiple);
} else if (fromExtension === 'pdf' && ['docx', 'odt'].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if (fromExtension === 'pdf' && ['pptx', 'odp'].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) {
formData.append("outputFormat", toExtension);
} else if ((isImageFormat(fromExtension) || fromExtension === 'image') && toExtension === 'pdf') {
formData.append("fitOption", imageOptions.fitOption);
formData.append("colorType", imageOptions.colorType);
formData.append("autoRotate", imageOptions.autoRotate.toString());
} else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') {
formData.append("zoom", htmlOptions.zoomLevel.toString());
} else if (fromExtension === 'eml' && toExtension === 'pdf') {
formData.append("includeAttachments", emailOptions.includeAttachments.toString());
formData.append("maxAttachmentSizeMB", emailOptions.maxAttachmentSizeMB.toString());
formData.append("downloadHtml", emailOptions.downloadHtml.toString());
formData.append("includeAllRecipients", emailOptions.includeAllRecipients.toString());
} else if (fromExtension === 'pdf' && toExtension === 'pdfa') {
formData.append("outputFormat", pdfaOptions.outputFormat);
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
formData.append("pageNumbers", "all");
}
return formData;
}, []);
const createOperation = useCallback((
parameters: ConvertParameters,
selectedFiles: File[]
): { operation: FileOperation; operationId: string; fileId: string } => {
const operationId = `convert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const fileId = selectedFiles[0].name;
const operation: FileOperation = {
id: operationId,
type: 'convert',
timestamp: Date.now(),
fileIds: selectedFiles.map(f => f.name),
status: 'pending',
metadata: {
originalFileName: selectedFiles[0].name,
parameters: {
fromExtension: parameters.fromExtension,
toExtension: parameters.toExtension,
imageOptions: parameters.imageOptions,
htmlOptions: parameters.htmlOptions,
emailOptions: parameters.emailOptions,
pdfaOptions: parameters.pdfaOptions,
},
fileSize: selectedFiles[0].size
}
};
return { operation, operationId, fileId };
}, []);
const processResults = useCallback(async (blob: Blob, filename: string) => {
try {
// For single file conversions, create a file directly
const convertedFile = new File([blob], filename, { type: blob.type });
// Set local state for preview
setFiles([convertedFile]);
setThumbnails([]);
setIsGeneratingThumbnails(true);
// Add converted file to FileContext for future use
await addFiles([convertedFile]);
// Generate thumbnail for preview
try {
const thumbnail = await generateThumbnailForFile(convertedFile);
setThumbnails([thumbnail]);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${filename}:`, error);
setThumbnails(['']);
}
setIsGeneratingThumbnails(false);
} catch (error) {
console.warn('Failed to process conversion result:', error);
}
}, [addFiles]);
const executeOperation = useCallback(async (
parameters: ConvertParameters,
selectedFiles: File[]
) => {
if (selectedFiles.length === 0) {
setStatus(t("noFileSelected"));
return;
}
if (shouldProcessFilesSeparately(selectedFiles, parameters)) {
await executeMultipleSeparateFiles(parameters, selectedFiles);
} else {
await executeSingleCombinedOperation(parameters, selectedFiles);
}
}, [t]);
const executeMultipleSeparateFiles = async (
parameters: ConvertParameters,
selectedFiles: File[]
) => {
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
const results: File[] = [];
try {
// Process each file separately
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
setStatus(t("convert.processingFile", `Processing file ${i + 1} of ${selectedFiles.length}...`));
const fileExtension = detectFileExtension(file.name);
let endpoint = getEndpointUrl(fileExtension, parameters.toExtension);
let fileSpecificParams = { ...parameters, fromExtension: fileExtension };
if (!endpoint && parameters.toExtension === 'pdf') {
endpoint = '/api/v1/convert/file/pdf';
console.log(`Using file-to-pdf fallback for ${fileExtension} file: ${file.name}`);
}
if (!endpoint) {
console.error(`No endpoint available for ${fileExtension} to ${parameters.toExtension}`);
continue;
}
const { operation, operationId, fileId } = createOperation(fileSpecificParams, [file]);
const formData = buildFormData(fileSpecificParams, [file]);
recordOperation(fileId, operation);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
// Use utility function to create file from response
const convertedFile = createFileFromResponse(
response.data,
response.headers,
file.name,
parameters.toExtension
);
results.push(convertedFile);
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(`Error converting file ${file.name}:`, error);
markOperationFailed(fileId, operationId);
}
}
if (results.length > 0) {
const generatedThumbnails = await generateThumbnailsForFiles(results);
setFiles(results);
setThumbnails(generatedThumbnails);
await addFiles(results);
try {
const { url, filename } = await createDownloadInfo(results);
setDownloadUrl(url);
setDownloadFilename(filename);
} catch (error) {
console.error('Failed to create download info:', error);
const url = window.URL.createObjectURL(results[0]);
setDownloadUrl(url);
setDownloadFilename(results[0].name);
}
setStatus(t("convert.multipleFilesComplete", `Converted ${results.length} files successfully`));
} else {
setErrorMessage(t("convert.errorAllFilesFailed", "All files failed to convert"));
}
} catch (error) {
console.error('Error in multiple operations:', error);
setErrorMessage(t("convert.errorMultipleConversion", "An error occurred while converting multiple files"));
} finally {
setIsLoading(false);
}
};
const executeSingleCombinedOperation = async (
parameters: ConvertParameters,
selectedFiles: File[]
) => {
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
const formData = buildFormData(parameters, selectedFiles);
// Get endpoint using utility function
const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension);
if (!endpoint) {
setErrorMessage(t("convert.errorNotSupported", { from: parameters.fromExtension, to: parameters.toExtension }));
return;
}
recordOperation(fileId, operation);
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
try {
const response = await axios.post(endpoint, formData, { responseType: "blob" });
// Use utility function to create file from response
const originalFileName = selectedFiles.length === 1
? selectedFiles[0].name
: 'combined_files.pdf'; // Default extension for combined files
const convertedFile = createFileFromResponse(
response.data,
response.headers,
originalFileName,
parameters.toExtension
);
const url = window.URL.createObjectURL(convertedFile);
setDownloadUrl(url);
setDownloadFilename(convertedFile.name);
setStatus(t("downloadComplete"));
await processResults(new Blob([convertedFile]), convertedFile.name);
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(error);
let errorMsg = t("convert.errorConversion", "An error occurred while converting the file.");
if (error.response?.data && typeof error.response.data === 'string') {
errorMsg = error.response.data;
} else if (error.message) {
errorMsg = error.message;
}
setErrorMessage(errorMsg);
markOperationFailed(fileId, operationId, errorMsg);
} finally {
setIsLoading(false);
}
};
const resetResults = useCallback(() => {
// Clean up blob URLs to prevent memory leaks
if (downloadUrl) {
window.URL.revokeObjectURL(downloadUrl);
}
setFiles([]);
setThumbnails([]);
setIsGeneratingThumbnails(false);
setDownloadUrl(null);
setDownloadFilename('');
setStatus('');
setErrorMessage(null);
setIsLoading(false);
}, [downloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (downloadUrl) {
window.URL.revokeObjectURL(downloadUrl);
}
};
}, [downloadUrl]);
return {
executeOperation,
// Flattened result properties for cleaner access
files,
thumbnails,
isGeneratingThumbnails,
downloadUrl,
downloadFilename,
status,
errorMessage,
isLoading,
// Result management functions
resetResults,
clearError,
};
};
@@ -0,0 +1,223 @@
/**
* Unit tests for useConvertParameters hook
*/
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from './useConvertParameters';
describe('useConvertParameters', () => {
describe('Parameter Management', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useConvertParameters());
expect(result.current.parameters.fromExtension).toBe('');
expect(result.current.parameters.toExtension).toBe('');
expect(result.current.parameters.imageOptions.colorType).toBe('color');
expect(result.current.parameters.imageOptions.dpi).toBe(300);
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('multiple');
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.0);
expect(result.current.parameters.emailOptions.includeAttachments).toBe(true);
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(10);
expect(result.current.parameters.emailOptions.downloadHtml).toBe(false);
expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(false);
expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa-1');
});
test('should update individual parameters', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
});
expect(result.current.parameters.fromExtension).toBe('pdf');
expect(result.current.parameters.toExtension).toBe(''); // Should not affect other params
});
test('should update nested image options', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('imageOptions', {
colorType: 'grayscale',
dpi: 150,
singleOrMultiple: 'single'
});
});
expect(result.current.parameters.imageOptions.colorType).toBe('grayscale');
expect(result.current.parameters.imageOptions.dpi).toBe(150);
expect(result.current.parameters.imageOptions.singleOrMultiple).toBe('single');
});
test('should update nested HTML options', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('htmlOptions', {
zoomLevel: 1.5
});
});
expect(result.current.parameters.htmlOptions.zoomLevel).toBe(1.5);
});
test('should update nested email options', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('emailOptions', {
includeAttachments: false,
maxAttachmentSizeMB: 20,
downloadHtml: true,
includeAllRecipients: true
});
});
expect(result.current.parameters.emailOptions.includeAttachments).toBe(false);
expect(result.current.parameters.emailOptions.maxAttachmentSizeMB).toBe(20);
expect(result.current.parameters.emailOptions.downloadHtml).toBe(true);
expect(result.current.parameters.emailOptions.includeAllRecipients).toBe(true);
});
test('should update nested PDF/A options', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('pdfaOptions', {
outputFormat: 'pdfa'
});
});
expect(result.current.parameters.pdfaOptions.outputFormat).toBe('pdfa');
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
result.current.updateParameter('toExtension', 'png');
});
expect(result.current.parameters.fromExtension).toBe('pdf');
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters.fromExtension).toBe('');
expect(result.current.parameters.toExtension).toBe('');
});
});
describe('Parameter Validation', () => {
test('should validate parameters correctly', () => {
const { result } = renderHook(() => useConvertParameters());
// No parameters - should be invalid
expect(result.current.validateParameters()).toBe(false);
// Only fromExtension - should be invalid
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
});
expect(result.current.validateParameters()).toBe(false);
// Both extensions with supported conversion - should be valid
act(() => {
result.current.updateParameter('toExtension', 'png');
});
expect(result.current.validateParameters()).toBe(true);
});
test('should validate unsupported conversions', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
result.current.updateParameter('toExtension', 'unsupported');
});
expect(result.current.validateParameters()).toBe(false);
});
});
describe('Endpoint Generation', () => {
test('should generate correct endpoint names', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
result.current.updateParameter('toExtension', 'png');
});
const endpointName = result.current.getEndpointName();
expect(endpointName).toBe('pdf-to-img');
});
test('should generate correct endpoint URLs', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
result.current.updateParameter('toExtension', 'png');
});
const endpoint = result.current.getEndpoint();
expect(endpoint).toBe('/api/v1/convert/pdf/img');
});
test('should return empty strings for invalid conversions', () => {
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'invalid');
result.current.updateParameter('toExtension', 'invalid');
});
expect(result.current.getEndpointName()).toBe('');
expect(result.current.getEndpoint()).toBe('');
});
});
describe('Available Extensions', () => {
test('should return available extensions for valid source format', () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('pdf');
expect(availableExtensions.length).toBeGreaterThan(0);
expect(availableExtensions.some(ext => ext.value === 'png')).toBe(true);
expect(availableExtensions.some(ext => ext.value === 'jpg')).toBe(true);
});
test('should return empty array for invalid source format', () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('invalid');
expect(availableExtensions).toEqual([{
"group": "Document",
"label": "PDF",
"value": "pdf",
}]);
});
test('should return empty array for empty source format', () => {
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('');
expect(availableExtensions).toEqual([]);
});
});
});
@@ -0,0 +1,327 @@
import { useState, useEffect } from 'react';
import {
COLOR_TYPES,
OUTPUT_OPTIONS,
FIT_OPTIONS,
TO_FORMAT_OPTIONS,
CONVERSION_MATRIX,
type ColorType,
type OutputOption,
type FitOption
} from '../../../constants/convertConstants';
import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils';
export interface ConvertParameters {
fromExtension: string;
toExtension: string;
imageOptions: {
colorType: ColorType;
dpi: number;
singleOrMultiple: OutputOption;
fitOption: FitOption;
autoRotate: boolean;
combineImages: boolean;
};
htmlOptions: {
zoomLevel: number;
};
emailOptions: {
includeAttachments: boolean;
maxAttachmentSizeMB: number;
downloadHtml: boolean;
includeAllRecipients: boolean;
};
pdfaOptions: {
outputFormat: string;
};
isSmartDetection: boolean;
smartDetectionType: 'mixed' | 'images' | 'web' | 'none';
}
export interface ConvertParametersHook {
parameters: ConvertParameters;
updateParameter: (parameter: keyof ConvertParameters, value: any) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
getEndpoint: () => string;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
analyzeFileTypes: (files: Array<{name: string}>) => void;
}
const initialParameters: ConvertParameters = {
fromExtension: '',
toExtension: '',
imageOptions: {
colorType: COLOR_TYPES.COLOR,
dpi: 300,
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
fitOption: FIT_OPTIONS.MAINTAIN_ASPECT,
autoRotate: true,
combineImages: true,
},
htmlOptions: {
zoomLevel: 1.0,
},
emailOptions: {
includeAttachments: true,
maxAttachmentSizeMB: 10,
downloadHtml: false,
includeAllRecipients: false,
},
pdfaOptions: {
outputFormat: 'pdfa-1',
},
isSmartDetection: false,
smartDetectionType: 'none',
};
export const useConvertParameters = (): ConvertParametersHook => {
const [parameters, setParameters] = useState<ConvertParameters>(initialParameters);
const updateParameter = (parameter: keyof ConvertParameters, value: any) => {
setParameters(prev => ({ ...prev, [parameter]: value }));
};
const resetParameters = () => {
setParameters(initialParameters);
};
const validateParameters = () => {
const { fromExtension, toExtension } = parameters;
if (!fromExtension || !toExtension) return false;
// Handle dynamic format identifiers (file-<extension>)
let supportedToExtensions: string[] = [];
if (fromExtension.startsWith('file-')) {
// Dynamic format - use 'any' conversion options
supportedToExtensions = CONVERSION_MATRIX['any'] || [];
} else {
// Regular format - check conversion matrix
supportedToExtensions = CONVERSION_MATRIX[fromExtension] || [];
}
if (!supportedToExtensions.includes(toExtension)) {
return false;
}
return true;
};
const getEndpointName = () => {
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = parameters;
if (isSmartDetection) {
if (smartDetectionType === 'mixed') {
// Mixed file types -> PDF using file-to-pdf endpoint
return 'file-to-pdf';
} else if (smartDetectionType === 'images') {
// All images -> PDF using img-to-pdf endpoint
return 'img-to-pdf';
} else if (smartDetectionType === 'web') {
// All web files -> PDF using html-to-pdf endpoint
return 'html-to-pdf';
}
}
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith('file-')) {
// Dynamic format - use file-to-pdf endpoint
return 'file-to-pdf';
}
return getEndpointNameUtil(fromExtension, toExtension);
};
const getEndpoint = () => {
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = parameters;
if (isSmartDetection) {
if (smartDetectionType === 'mixed') {
// Mixed file types -> PDF using file-to-pdf endpoint
return '/api/v1/convert/file/pdf';
} else if (smartDetectionType === 'images') {
// All images -> PDF using img-to-pdf endpoint
return '/api/v1/convert/img/pdf';
} else if (smartDetectionType === 'web') {
// All web files -> PDF using html-to-pdf endpoint
return '/api/v1/convert/html/pdf';
}
}
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith('file-')) {
// Dynamic format - use file-to-pdf endpoint
return '/api/v1/convert/file/pdf';
}
return getEndpointUrl(fromExtension, toExtension);
};
const getAvailableToExtensions = (fromExtension: string) => {
if (!fromExtension) return [];
// Handle dynamic format identifiers (file-<extension>)
if (fromExtension.startsWith('file-')) {
// Dynamic format - use 'any' conversion options (file-to-pdf)
const supportedExtensions = CONVERSION_MATRIX['any'] || [];
return TO_FORMAT_OPTIONS.filter(option =>
supportedExtensions.includes(option.value)
);
}
let supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
// If no explicit conversion exists, but file-to-pdf might be available,
// fall back to 'any' conversion (which converts unknown files to PDF via file-to-pdf)
if (supportedExtensions.length === 0 && fromExtension !== 'any') {
supportedExtensions = CONVERSION_MATRIX['any'] || [];
}
return TO_FORMAT_OPTIONS.filter(option =>
supportedExtensions.includes(option.value)
);
};
const analyzeFileTypes = (files: Array<{name: string}>) => {
if (files.length === 0) {
// No files - only reset smart detection, keep user's format choices
setParameters(prev => ({
...prev,
isSmartDetection: false,
smartDetectionType: 'none'
// Don't reset fromExtension and toExtension - let user keep their choices
}));
return;
}
if (files.length === 1) {
// Single file - use regular detection with smart target selection
const detectedExt = detectFileExtensionUtil(files[0].name);
let fromExt = detectedExt;
let availableTargets = detectedExt ? CONVERSION_MATRIX[detectedExt] || [] : [];
// If no explicit conversion exists for this file type, create a dynamic format entry
// and fall back to 'any' conversion logic for the actual endpoint
if (availableTargets.length === 0 && detectedExt) {
fromExt = `file-${detectedExt}`; // Create dynamic format identifier
availableTargets = CONVERSION_MATRIX['any'] || [];
} else if (availableTargets.length === 0) {
// No extension detected - fall back to 'any'
fromExt = 'any';
availableTargets = CONVERSION_MATRIX['any'] || [];
}
setParameters(prev => {
// Check if current toExtension is still valid for the new fromExtension
const currentToExt = prev.toExtension;
const isCurrentToExtValid = availableTargets.includes(currentToExt);
// Auto-select target only if:
// 1. No current target is set, OR
// 2. Current target is invalid for new source type, OR
// 3. There's only one possible target (forced conversion)
let newToExtension = currentToExt;
if (!currentToExt || !isCurrentToExtValid) {
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
}
return {
...prev,
isSmartDetection: false,
smartDetectionType: 'none',
fromExtension: fromExt,
toExtension: newToExtension
};
});
return;
}
// Multiple files - analyze file types
const extensions = files.map(file => detectFileExtensionUtil(file.name));
const uniqueExtensions = [...new Set(extensions)];
if (uniqueExtensions.length === 1) {
// All files are the same type - use regular detection with smart target selection
const detectedExt = uniqueExtensions[0];
let fromExt = detectedExt;
let availableTargets = CONVERSION_MATRIX[detectedExt] || [];
// If no explicit conversion exists for this file type, fall back to 'any'
if (availableTargets.length === 0) {
fromExt = 'any';
availableTargets = CONVERSION_MATRIX['any'] || [];
}
setParameters(prev => {
// Check if current toExtension is still valid for the new fromExtension
const currentToExt = prev.toExtension;
const isCurrentToExtValid = availableTargets.includes(currentToExt);
// Auto-select target only if:
// 1. No current target is set, OR
// 2. Current target is invalid for new source type, OR
// 3. There's only one possible target (forced conversion)
let newToExtension = currentToExt;
if (!currentToExt || !isCurrentToExtValid) {
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
}
return {
...prev,
isSmartDetection: false,
smartDetectionType: 'none',
fromExtension: fromExt,
toExtension: newToExtension
};
});
} else {
// Mixed file types
const allImages = uniqueExtensions.every(ext => isImageFormat(ext));
const allWeb = uniqueExtensions.every(ext => isWebFormat(ext));
if (allImages) {
// All files are images - use image-to-pdf conversion
setParameters(prev => ({
...prev,
isSmartDetection: true,
smartDetectionType: 'images',
fromExtension: 'image',
toExtension: 'pdf'
}));
} else if (allWeb) {
// All files are web files - use html-to-pdf conversion
setParameters(prev => ({
...prev,
isSmartDetection: true,
smartDetectionType: 'web',
fromExtension: 'html',
toExtension: 'pdf'
}));
} else {
// Mixed non-image types - use file-to-pdf conversion
setParameters(prev => ({
...prev,
isSmartDetection: true,
smartDetectionType: 'mixed',
fromExtension: 'any',
toExtension: 'pdf'
}));
}
}
};
return {
parameters,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
getEndpoint,
getAvailableToExtensions,
analyzeFileTypes,
};
};
@@ -0,0 +1,365 @@
/**
* Tests for auto-detection and smart conversion features in useConvertParameters
* This covers the analyzeFileTypes function and related smart detection logic
*/
import { describe, test, expect } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useConvertParameters } from './useConvertParameters';
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Single File Detection', () => {
test('should detect single file extension and set auto-target', () => {
const { result } = renderHook(() => useConvertParameters());
const pdfFile = [{ name: 'document.pdf' }];
act(() => {
result.current.analyzeFileTypes(pdfFile);
});
expect(result.current.parameters.fromExtension).toBe('pdf');
expect(result.current.parameters.toExtension).toBe(''); // No auto-selection for multiple targets
expect(result.current.parameters.isSmartDetection).toBe(false);
expect(result.current.parameters.smartDetectionType).toBe('none');
});
test('should handle unknown file types with file-to-pdf fallback', () => {
const { result } = renderHook(() => useConvertParameters());
const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }];
act(() => {
result.current.analyzeFileTypes(unknownFile);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf
expect(result.current.parameters.isSmartDetection).toBe(true);
});
test('should handle files without extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const noExtFile = [{ name: 'document' }];
act(() => {
result.current.analyzeFileTypes(noExtFile);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf'); // Fallback to file-to-pdf
});
});
describe('Multiple Identical Files', () => {
test('should detect multiple PDF files and set auto-target', () => {
const { result } = renderHook(() => useConvertParameters());
const pdfFiles = [
{ name: 'doc1.pdf' },
{ name: 'doc2.pdf' },
{ name: 'doc3.pdf' }
];
act(() => {
result.current.analyzeFileTypes(pdfFiles);
});
expect(result.current.parameters.fromExtension).toBe('pdf');
expect(result.current.parameters.toExtension).toBe(''); // Auto-selected
expect(result.current.parameters.isSmartDetection).toBe(false);
expect(result.current.parameters.smartDetectionType).toBe('none');
});
test('should handle multiple unknown file types with fallback', () => {
const { result } = renderHook(() => useConvertParameters());
const unknownFiles = [
{ name: 'file1.xyz' },
{ name: 'file2.xyz' }
];
act(() => {
result.current.analyzeFileTypes(unknownFiles);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(false);
});
});
describe('Smart Detection - All Images', () => {
test('should detect all image files and enable smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.jpg' },
{ name: 'photo2.png' },
{ name: 'photo3.gif' }
];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.parameters.fromExtension).toBe('image');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('images');
});
test('should handle mixed case image extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.JPG' },
{ name: 'photo2.PNG' }
];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('images');
});
});
describe('Smart Detection - All Web Files', () => {
test('should detect all web files and enable web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.html' },
{ name: 'archive.zip' }
];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.parameters.fromExtension).toBe('html');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('web');
});
test('should handle mixed case web extensions', () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.HTML' },
{ name: 'archive.ZIP' }
];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('web');
});
test('should detect multiple web files and enable web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const zipFiles = [
{ name: 'site1.zip' },
{ name: 'site2.html' }
];
act(() => {
result.current.analyzeFileTypes(zipFiles);
});
expect(result.current.parameters.fromExtension).toBe('html');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('web');
});
});
describe('Smart Detection - Mixed File Types', () => {
test('should detect mixed file types and enable smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
{ name: 'spreadsheet.xlsx' },
{ name: 'presentation.pptx' }
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf');
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('mixed');
});
test('should detect mixed images and documents as mixed type', () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'photo.jpg' },
{ name: 'document.pdf' },
{ name: 'text.txt' }
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('mixed');
});
test('should handle mixed with unknown file types', () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
{ name: 'unknown.xyz' },
{ name: 'noextension' }
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('mixed');
});
});
describe('Smart Detection Endpoint Resolution', () => {
test('should return correct endpoint for image smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.jpg' },
{ name: 'photo2.png' }
];
act(() => {
result.current.analyzeFileTypes(imageFiles);
});
expect(result.current.getEndpointName()).toBe('img-to-pdf');
expect(result.current.getEndpoint()).toBe('/api/v1/convert/img/pdf');
});
test('should return correct endpoint for web smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.html' },
{ name: 'archive.zip' }
];
act(() => {
result.current.analyzeFileTypes(webFiles);
});
expect(result.current.getEndpointName()).toBe('html-to-pdf');
expect(result.current.getEndpoint()).toBe('/api/v1/convert/html/pdf');
});
test('should return correct endpoint for mixed smart detection', () => {
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
{ name: 'spreadsheet.xlsx' }
];
act(() => {
result.current.analyzeFileTypes(mixedFiles);
});
expect(result.current.getEndpointName()).toBe('file-to-pdf');
expect(result.current.getEndpoint()).toBe('/api/v1/convert/file/pdf');
});
});
describe('Auto-Target Selection Logic', () => {
test('should select single available target automatically', () => {
const { result } = renderHook(() => useConvertParameters());
// Markdown has only one conversion target (PDF)
const mdFile = [{ name: 'readme.md' }];
act(() => {
result.current.analyzeFileTypes(mdFile);
});
expect(result.current.parameters.fromExtension).toBe('md');
expect(result.current.parameters.toExtension).toBe('pdf'); // Only available target
});
test('should not auto-select when multiple targets available', () => {
const { result } = renderHook(() => useConvertParameters());
// PDF has multiple conversion targets, so no auto-selection
const pdfFile = [{ name: 'document.pdf' }];
act(() => {
result.current.analyzeFileTypes(pdfFile);
});
expect(result.current.parameters.fromExtension).toBe('pdf');
// Should NOT auto-select when multiple targets available
expect(result.current.parameters.toExtension).toBe('');
});
});
describe('Edge Cases', () => {
test('should handle empty file names', () => {
const { result } = renderHook(() => useConvertParameters());
const emptyFiles = [{ name: '' }];
act(() => {
result.current.analyzeFileTypes(emptyFiles);
});
expect(result.current.parameters.fromExtension).toBe('any');
expect(result.current.parameters.toExtension).toBe('pdf');
});
test('should handle malformed file objects', () => {
const { result } = renderHook(() => useConvertParameters());
const malformedFiles = [
{ name: 'valid.pdf' },
// @ts-ignore - Testing runtime resilience
{ name: null },
// @ts-ignore
{ name: undefined }
];
act(() => {
result.current.analyzeFileTypes(malformedFiles);
});
// Should still process the valid file and handle gracefully
expect(result.current.parameters.isSmartDetection).toBe(true);
expect(result.current.parameters.smartDetectionType).toBe('mixed');
});
});
});
@@ -0,0 +1,66 @@
import { useState, useEffect } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
export interface PdfSignatureDetectionResult {
hasDigitalSignatures: boolean;
isChecking: boolean;
}
export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => {
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
const [isChecking, setIsChecking] = useState(false);
useEffect(() => {
const checkForDigitalSignatures = async () => {
if (files.length === 0) {
setHasDigitalSignatures(false);
return;
}
setIsChecking(true);
let foundSignature = false;
try {
// Set up PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-legacy/pdf.worker.mjs';
for (const file of files) {
const arrayBuffer = await file.arrayBuffer();
try {
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const annotations = await page.getAnnotations({ intent: 'display' });
annotations.forEach(annotation => {
if (annotation.subtype === 'Widget' && annotation.fieldType === 'Sig') {
foundSignature = true;
}
});
if (foundSignature) break;
}
} catch (error) {
console.warn('Error analyzing PDF for signatures:', error);
}
if (foundSignature) break;
}
} catch (error) {
console.warn('Error checking for digital signatures:', error);
}
setHasDigitalSignatures(foundSignature);
setIsChecking(false);
};
checkForDigitalSignatures();
}, [files]);
return {
hasDigitalSignatures,
isChecking
};
};
+28 -1
View File
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
import ApiIcon from "@mui/icons-material/Api";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
@@ -27,6 +28,33 @@ const toolDefinitions: Record<string, ToolDefinition> = {
description: "Reduce PDF file size",
endpoints: ["compress-pdf"]
},
convert: {
id: "convert",
icon: <SwapHorizIcon />,
component: React.lazy(() => import("../tools/Convert")),
maxFiles: -1,
category: "manipulation",
description: "Change to and from PDF and other formats",
endpoints: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"],
supportedFormats: [
// Microsoft Office
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
// OpenDocument
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
// Text formats
"txt", "text", "xml", "rtf", "html", "lwp", "md",
// Images
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
// StarOffice
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
]
},
swagger: {
id: "swagger",
icon: <ApiIcon />,
@@ -50,7 +78,6 @@ const toolDefinitions: Record<string, ToolDefinition> = {
};
interface ToolManagementResult {
selectedToolKey: string | null;
selectedTool: Tool | null;