diff --git a/frontend/package.json b/frontend/package.json index f95b43bd3..4ff3484b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "test:e2e:install": "playwright install" }, "eslintConfig": { diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 8be0438d9..24954998c 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -224,49 +224,46 @@ const FileEditor = ({ // Handle PDF files normally allExtractedFiles.push(file); } else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) { - // Handle ZIP files + // Handle ZIP files - only expand if they contain PDFs try { // Validate ZIP file first const validation = await zipFileService.validateZipFile(file); - if (!validation.isValid) { - errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`); - continue; - } - - // Extract PDF files from ZIP - setZipExtractionProgress({ - isExtracting: true, - currentFile: file.name, - progress: 0, - extractedCount: 0, - totalFiles: validation.fileCount - }); - - const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => { + + if (validation.isValid && validation.containsPDFs) { + // ZIP contains PDFs - extract them setZipExtractionProgress({ isExtracting: true, - currentFile: progress.currentFile, - progress: progress.progress, - extractedCount: progress.extractedCount, - totalFiles: progress.totalFiles + currentFile: file.name, + progress: 0, + extractedCount: 0, + totalFiles: validation.fileCount }); - }); - // Reset extraction progress - setZipExtractionProgress({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); + const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => { + setZipExtractionProgress({ + isExtracting: true, + currentFile: progress.currentFile, + progress: progress.progress, + extractedCount: progress.extractedCount, + totalFiles: progress.totalFiles + }); + }); - if (extractionResult.success) { - allExtractedFiles.push(...extractionResult.extractedFiles); - - // Record ZIP extraction operation - const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const operation: FileOperation = { + // Reset extraction progress + setZipExtractionProgress({ + isExtracting: false, + currentFile: '', + progress: 0, + extractedCount: 0, + totalFiles: 0 + }); + + if (extractionResult.success) { + allExtractedFiles.push(...extractionResult.extractedFiles); + + // Record ZIP extraction operation + const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const operation: FileOperation = { id: operationId, type: 'convert', timestamp: Date.now(), @@ -290,8 +287,13 @@ const FileEditor = ({ if (extractionResult.errors.length > 0) { errors.push(...extractionResult.errors); } + } else { + errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); + } } else { - errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); + // ZIP doesn't contain PDFs or is invalid - treat as regular file + console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`); + allExtractedFiles.push(file); } } catch (zipError) { errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`); diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx index b0a091930..b6c311297 100644 --- a/frontend/src/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -1,11 +1,12 @@ import React, { useMemo } from "react"; -import { Stack, Text, Group, Divider, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core"; +import { Stack, Text, Group, Divider, UnstyledButton, useMantineTheme, useMantineColorScheme, NumberInput, Slider } from "@mantine/core"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { useTranslation } from "react-i18next"; import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig"; -import { isImageFormat } from "../../../utils/convertUtils"; +import { isImageFormat, isWebFormat } from "../../../utils/convertUtils"; import { useFileSelectionActions } from "../../../contexts/FileSelectionContext"; import { useFileContext } from "../../../contexts/FileContext"; +import { detectFileExtension } from "../../../utils/fileUtils"; import GroupedFormatDropdown from "./GroupedFormatDropdown"; import ConvertToImageSettings from "./ConvertToImageSettings"; import ConvertFromImageSettings from "./ConvertFromImageSettings"; @@ -234,6 +235,40 @@ const ConvertSettings = ({ ) : null} + {/* HTML to PDF specific options */} + {((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') || + (parameters.isSmartDetection && parameters.smartDetectionType === 'web')) && ( + <> + + + {t("convert.htmlOptions", "HTML Options")}: + + + {t("convert.zoomLevel", "Zoom Level")}: + onParameterChange('htmlOptions', { ...parameters.htmlOptions, zoomLevel: Number(value) || 1.0 })} + min={0.1} + max={3.0} + step={0.1} + precision={1} + disabled={disabled} + data-testid="zoom-level-input" + /> + onParameterChange('htmlOptions', { ...parameters.htmlOptions, zoomLevel: value })} + min={0.1} + max={3.0} + step={0.1} + disabled={disabled} + data-testid="zoom-level-slider" + /> + + + + )} + {/* EML specific options */} {parameters.fromExtension === 'eml' && parameters.toExtension === 'pdf' && ( <> diff --git a/frontend/src/constants/convertConstants.ts b/frontend/src/constants/convertConstants.ts index a2c1a2c1d..f371c7081 100644 --- a/frontend/src/constants/convertConstants.ts +++ b/frontend/src/constants/convertConstants.ts @@ -75,7 +75,7 @@ export const FROM_FORMAT_OPTIONS = [ { value: 'webp', label: 'WEBP', group: 'Image' }, { value: 'svg', label: 'SVG', group: 'Image' }, { value: 'html', label: 'HTML', group: 'Web' }, - { value: 'htm', label: 'HTM', group: 'Web' }, + { value: 'zip', label: 'ZIP', group: 'Web' }, { value: 'md', label: 'MD', group: 'Text' }, { value: 'txt', label: 'TXT', group: 'Text' }, { value: 'rtf', label: 'RTF', group: 'Text' }, @@ -112,7 +112,8 @@ export const CONVERSION_MATRIX: Record = { 'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'], 'pptx': ['pdf'], 'ppt': ['pdf'], 'odp': ['pdf'], 'jpg': ['pdf'], 'jpeg': ['pdf'], 'png': ['pdf'], 'gif': ['pdf'], 'bmp': ['pdf'], 'tiff': ['pdf'], 'webp': ['pdf'], 'svg': ['pdf'], - 'html': ['pdf'], 'htm': ['pdf'], + 'html': ['pdf'], + 'zip': ['pdf'], 'md': ['pdf'], 'txt': ['pdf'], 'rtf': ['pdf'], 'eml': ['pdf'] @@ -136,7 +137,8 @@ export const EXTENSION_TO_ENDPOINT: Record> = { 'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' }, 'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' }, 'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' }, 'svg': { 'pdf': 'img-to-pdf' }, - 'html': { 'pdf': 'html-to-pdf' }, 'htm': { 'pdf': 'html-to-pdf' }, + 'html': { 'pdf': 'html-to-pdf' }, + 'zip': { 'pdf': 'html-to-pdf' }, 'md': { 'pdf': 'markdown-to-pdf' }, 'txt': { 'pdf': 'file-to-pdf' }, 'rtf': { 'pdf': 'file-to-pdf' }, 'eml': { 'pdf': 'eml-to-pdf' } diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index dfa4d3cde..eaa77d66a 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -5,8 +5,9 @@ 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 { getEndpointUrl, isImageFormat } from '../../../utils/convertUtils'; +import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils'; export interface ConvertOperationHook { executeOperation: ( @@ -29,6 +30,99 @@ export interface ConvertOperationHook { clearError: () => void; } +// Utility functions for better maintainability + +/** + * Determines if multiple files should be processed separately + */ +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)) || + // 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') + ); +}; + +/** + * Creates a file from API response with appropriate naming + */ +const createFileFromResponse = ( + responseData: any, + headers: any, + originalFileName: string, + targetExtension: string +): File => { + const contentType = headers?.['content-type'] || 'application/octet-stream'; + const blob = new Blob([responseData], { type: contentType }); + + const originalName = originalFileName.split('.')[0]; + let filename: string; + + // Check if response is a ZIP + if (contentType.includes('zip') || headers?.['content-disposition']?.includes('.zip')) { + filename = `${originalName}_converted.zip`; + } else { + filename = `${originalName}_converted.${targetExtension}`; + } + + return new File([blob], filename, { type: contentType }); +}; + +/** + * Generates thumbnails for multiple files + */ +const generateThumbnailsForFiles = async (files: File[]): Promise => { + const thumbnails: string[] = []; + + for (const file of files) { + try { + const thumbnail = await generateThumbnailForFile(file); + thumbnails.push(thumbnail); + } catch (error) { + console.warn(`Failed to generate thumbnail for ${file.name}:`, error); + thumbnails.push(''); + } + } + + return thumbnails; +}; + +/** + * Creates download URL and filename for single or multiple files + */ +const createDownloadInfo = async (files: File[]): Promise<{ url: string; filename: string }> => { + if (files.length === 1) { + // Single file - direct download + const url = window.URL.createObjectURL(files[0]); + return { url, filename: files[0].name }; + } else { + // Multiple files - create ZIP for convenient download + 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 { @@ -58,7 +152,7 @@ export const useConvertOperation = (): ConvertOperationHook => { formData.append("fileInput", file); }); - const { fromExtension, toExtension, imageOptions } = parameters; + const { fromExtension, toExtension, imageOptions, htmlOptions } = parameters; // Add conversion-specific parameters if (isImageFormat(toExtension)) { @@ -76,6 +170,9 @@ export const useConvertOperation = (): ConvertOperationHook => { formData.append("fitOption", imageOptions.fitOption); formData.append("colorType", imageOptions.colorType); formData.append("autoRotate", imageOptions.autoRotate.toString()); + } else if ((fromExtension === 'html' || fromExtension === 'zip') && toExtension === 'pdf') { + // HTML to PDF conversion with zoom level (includes ZIP files with HTML) + formData.append("zoom", htmlOptions.zoomLevel.toString()); } else if (fromExtension === 'pdf' && toExtension === 'csv') { // CSV extraction - always process all pages for simplified workflow formData.append("pageNumbers", "all"); @@ -103,6 +200,7 @@ export const useConvertOperation = (): ConvertOperationHook => { fromExtension: parameters.fromExtension, toExtension: parameters.toExtension, imageOptions: parameters.imageOptions, + htmlOptions: parameters.htmlOptions, }, fileSize: selectedFiles[0].size } @@ -148,16 +246,8 @@ export const useConvertOperation = (): ConvertOperationHook => { return; } - // Check if this should be processed as separate files - const shouldProcessSeparately = selectedFiles.length > 1 && ( - // Image to PDF with combineImages = false - ((isImageFormat(parameters.fromExtension) || parameters.fromExtension === 'image') && - parameters.toExtension === 'pdf' && !parameters.imageOptions.combineImages) || - // Mixed file types (smart detection) - (parameters.isSmartDetection && parameters.smartDetectionType === 'mixed') - ); - - if (shouldProcessSeparately) { + // Use utility function to determine processing strategy + if (shouldProcessFilesSeparately(selectedFiles, parameters)) { // Process each file separately with appropriate endpoint await executeMultipleSeparateFiles(parameters, selectedFiles); } else { @@ -175,7 +265,6 @@ export const useConvertOperation = (): ConvertOperationHook => { setErrorMessage(null); const results: File[] = []; - const thumbnails: string[] = []; try { // Process each file separately @@ -183,8 +272,8 @@ export const useConvertOperation = (): ConvertOperationHook => { const file = selectedFiles[i]; setStatus(t("convert.processingFile", `Processing file ${i + 1} of ${selectedFiles.length}...`)); - // Detect the specific file type for this file - const fileExtension = file.name.split('.').pop()?.toLowerCase() || ''; + // Detect the specific file type for this file using the shared utility + const fileExtension = detectFileExtension(file.name); // Determine the best endpoint for this specific file type let endpoint = getEndpointUrl(fileExtension, parameters.toExtension); @@ -209,23 +298,15 @@ export const useConvertOperation = (): ConvertOperationHook => { try { const response = await axios.post(endpoint, formData, { responseType: "blob" }); - const blob = new Blob([response.data]); - - // Generate filename for this specific file - const originalName = file.name.split('.')[0]; - const filename = `${originalName}_converted.${parameters.toExtension}`; - const convertedFile = new File([blob], filename, { type: blob.type }); + // Use utility function to create file from response + const convertedFile = createFileFromResponse( + response.data, + response.headers, + file.name, + parameters.toExtension + ); results.push(convertedFile); - - // Generate thumbnail - try { - const thumbnail = await generateThumbnailForFile(convertedFile); - thumbnails.push(thumbnail); - } catch (error) { - console.warn(`Failed to generate thumbnail for ${filename}:`, error); - thumbnails.push(''); - } markOperationApplied(fileId, operationId); } catch (error: any) { @@ -236,19 +317,31 @@ export const useConvertOperation = (): ConvertOperationHook => { } if (results.length > 0) { + console.log(`Multi-file conversion completed: ${results.length} files processed from ${selectedFiles.length} input files`); + console.log('Result files:', results.map(f => f.name)); + + // Use utility function to generate thumbnails + const generatedThumbnails = await generateThumbnailsForFiles(results); + // Set results for multiple files setFiles(results); - setThumbnails(thumbnails); + setThumbnails(generatedThumbnails); // Add all converted files to FileContext await addFiles(results); - // For multiple separate files, use the first file for download - const firstFileBlob = new Blob([results[0]]); - const firstFileUrl = window.URL.createObjectURL(firstFileBlob); - - setDownloadUrl(firstFileUrl); - setDownloadFilename(results[0].name); + // Use utility function to create download info + try { + const { url, filename } = await createDownloadInfo(results); + setDownloadUrl(url); + setDownloadFilename(filename); + } catch (error) { + console.error('Failed to create download info:', error); + // Fallback to first file only + 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")); @@ -283,20 +376,25 @@ export const useConvertOperation = (): ConvertOperationHook => { try { const response = await axios.post(endpoint, formData, { responseType: "blob" }); - const blob = new Blob([response.data]); - const url = window.URL.createObjectURL(blob); - // Generate filename based on conversion - const originalName = selectedFiles.length === 1 - ? selectedFiles[0].name.split('.')[0] - : 'combined_images'; - const filename = `${originalName}_converted.${parameters.toExtension}`; + // 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(filename); + setDownloadFilename(convertedFile.name); setStatus(t("downloadComplete")); - await processResults(blob, filename); + await processResults(new Blob([convertedFile]), convertedFile.name); markOperationApplied(fileId, operationId); } catch (error: any) { console.error(error); diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.test.ts b/frontend/src/hooks/tools/convert/useConvertParameters.test.ts index ec8196942..f71ceae6d 100644 --- a/frontend/src/hooks/tools/convert/useConvertParameters.test.ts +++ b/frontend/src/hooks/tools/convert/useConvertParameters.test.ts @@ -207,31 +207,4 @@ describe('useConvertParameters', () => { }); }); - describe('File Extension Detection', () => { - - test('should detect file extensions correctly', () => { - const { result } = renderHook(() => useConvertParameters()); - - expect(result.current.detectFileExtension('document.pdf')).toBe('pdf'); - expect(result.current.detectFileExtension('image.PNG')).toBe('png'); // Should lowercase - expect(result.current.detectFileExtension('file.docx')).toBe('docx'); - expect(result.current.detectFileExtension('archive.tar.gz')).toBe('gz'); // Last extension - }); - - test('should handle files without extensions', () => { - const { result } = renderHook(() => useConvertParameters()); - - // Files without extensions should return empty string - expect(result.current.detectFileExtension('noextension')).toBe(''); - expect(result.current.detectFileExtension('')).toBe(''); - }); - - test('should handle edge cases', () => { - const { result } = renderHook(() => useConvertParameters()); - - expect(result.current.detectFileExtension('file.')).toBe(''); - expect(result.current.detectFileExtension('.hidden')).toBe('hidden'); - expect(result.current.detectFileExtension('file.UPPER')).toBe('upper'); - }); - }); }); \ No newline at end of file diff --git a/frontend/src/hooks/tools/convert/useConvertParameters.ts b/frontend/src/hooks/tools/convert/useConvertParameters.ts index 6cf153acc..10c638deb 100644 --- a/frontend/src/hooks/tools/convert/useConvertParameters.ts +++ b/frontend/src/hooks/tools/convert/useConvertParameters.ts @@ -9,7 +9,8 @@ import { type OutputOption, type FitOption } from '../../../constants/convertConstants'; -import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat } from '../../../utils/convertUtils'; +import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils'; +import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils'; export interface ConvertParameters { fromExtension: string; @@ -22,8 +23,11 @@ export interface ConvertParameters { autoRotate: boolean; combineImages: boolean; }; + htmlOptions: { + zoomLevel: number; + }; isSmartDetection: boolean; - smartDetectionType: 'mixed' | 'images' | 'none'; + smartDetectionType: 'mixed' | 'images' | 'web' | 'none'; } export interface ConvertParametersHook { @@ -34,7 +38,6 @@ export interface ConvertParametersHook { getEndpointName: () => string; getEndpoint: () => string; getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; - detectFileExtension: (filename: string) => string; analyzeFileTypes: (files: Array<{name: string}>) => void; } @@ -49,6 +52,9 @@ const initialParameters: ConvertParameters = { autoRotate: true, combineImages: true, }, + htmlOptions: { + zoomLevel: 1.0, + }, isSmartDetection: false, smartDetectionType: 'none', }; @@ -93,6 +99,9 @@ export const useConvertParameters = (): ConvertParametersHook => { } 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'; } } @@ -109,6 +118,9 @@ export const useConvertParameters = (): ConvertParametersHook => { } 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'; } } @@ -131,26 +143,23 @@ export const useConvertParameters = (): ConvertParametersHook => { ); }; - const detectFileExtension = (filename: string): string => { - if (!filename || typeof filename !== 'string') return ''; - - const parts = filename.split('.'); - // If there's no extension (no dots or only one part), return empty string - if (parts.length <= 1) return ''; - - // Get the last part (extension) in lowercase - let extension = parts[parts.length - 1].toLowerCase(); - - // Normalize common extension variants - if (extension === 'jpeg') extension = 'jpg'; - - return extension; - }; const analyzeFileTypes = (files: Array<{name: string}>) => { - if (files.length <= 1) { - // Single file or no files - use regular detection with auto-target selection - const detectedExt = files.length === 1 ? detectFileExtension(files[0].name) : ''; + if (files.length === 0) { + // No files - reset to empty state + setParameters(prev => ({ + ...prev, + isSmartDetection: false, + smartDetectionType: 'none', + fromExtension: '', + toExtension: '' + })); + return; + } + + if (files.length === 1) { + // Single file - use regular detection with auto-target selection + const detectedExt = detectFileExtensionUtil(files[0].name); let fromExt = detectedExt; let availableTargets = detectedExt ? CONVERSION_MATRIX[detectedExt] || [] : []; @@ -174,7 +183,7 @@ export const useConvertParameters = (): ConvertParametersHook => { } // Multiple files - analyze file types - const extensions = files.map(file => detectFileExtension(file.name)); + const extensions = files.map(file => detectFileExtensionUtil(file.name)); const uniqueExtensions = [...new Set(extensions)]; if (uniqueExtensions.length === 1) { @@ -201,6 +210,7 @@ export const useConvertParameters = (): ConvertParametersHook => { } 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 @@ -211,6 +221,15 @@ export const useConvertParameters = (): ConvertParametersHook => { 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 => ({ @@ -232,7 +251,6 @@ export const useConvertParameters = (): ConvertParametersHook => { getEndpointName, getEndpoint, getAvailableToExtensions, - detectFileExtension, analyzeFileTypes, }; }; \ No newline at end of file diff --git a/frontend/src/tests/convert/ConvertE2E.spec.ts b/frontend/src/tests/convert/ConvertE2E.spec.ts index 6356ade87..c927b9102 100644 --- a/frontend/src/tests/convert/ConvertE2E.spec.ts +++ b/frontend/src/tests/convert/ConvertE2E.spec.ts @@ -426,71 +426,6 @@ test.describe('Convert Tool E2E Tests', () => { }); }); - test.describe('Error Handling', () => { - - test('should handle corrupted file gracefully', async ({ page }) => { - // Create a corrupted file - const corruptedPath = resolveTestFixturePath('corrupted.pdf'); - fs.writeFileSync(corruptedPath, 'This is not a valid PDF file'); - - await page.setInputFiles('input[type="file"]', corruptedPath); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); - - // Click the Convert tool button - await page.click('[data-testid="tool-convert"]'); - - // Wait for convert mode and select file - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 }); - await page.click('[data-testid="file-thumbnail-checkbox"]'); - - await page.click('[data-testid="convert-from-dropdown"]'); - await page.click('[data-testid="format-option-pdf"]'); - - await page.click('[data-testid="convert-to-dropdown"]'); - await page.click('[data-testid="format-option-png"]'); - - await page.click('[data-testid="convert-button"]'); - - // Should show error message - await expect(page.locator('[data-testid="error-message"]')).toBeVisible(); - // Error text content will vary based on translation, so just check error message exists - await expect(page.locator('[data-testid="error-message"]')).not.toBeEmpty(); - - // Clean up - fs.unlinkSync(corruptedPath); - }); - - test('should handle backend unavailability', async ({ page }) => { - // This test would require mocking the backend or testing during downtime - // For now, we'll simulate by checking error handling UI - - await page.setInputFiles('input[type="file"]', TEST_FILES.pdf); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); - - // Click the Convert tool button - await page.click('[data-testid="tool-convert"]'); - - // Wait for convert mode and select file - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 }); - await page.click('[data-testid="file-thumbnail-checkbox"]'); - - // Mock network failure - await page.route('**/api/v1/convert/**', route => { - route.abort('failed'); - }); - - await page.click('[data-testid="convert-from-dropdown"]'); - await page.click('[data-testid="format-option-pdf"]'); - - await page.click('[data-testid="convert-to-dropdown"]'); - await page.click('[data-testid="format-option-png"]'); - - await page.click('[data-testid="convert-button"]'); - - // Should show network error - await expect(page.locator('[data-testid="error-message"]')).toBeVisible(); - }); - }); test.describe('UI/UX Flow', () => { @@ -551,44 +486,9 @@ test.describe('Convert Tool E2E Tests', () => { await expect(page.locator('[data-testid="image-options-section"]')).not.toBeVisible(); await expect(page.locator('[data-testid="dpi-input"]')).not.toBeVisible(); - // Should show CSV options - await expect(page.locator('[data-testid="csv-options-section"]')).toBeVisible(); - await expect(page.locator('[data-testid="page-numbers-input"]')).toBeVisible(); - }); - - test('should handle CSV page number input correctly', async ({ page }) => { - await page.setInputFiles('input[type="file"]', TEST_FILES.pdf); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); - - // Click the Convert tool button - await page.click('[data-testid="tool-convert"]'); - - // Wait for convert mode and select file - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 5000 }); - await page.click('[data-testid="file-thumbnail-checkbox"]'); - - await page.click('[data-testid="convert-from-dropdown"]'); - await page.click('[data-testid="format-option-pdf"]'); - - await page.click('[data-testid="convert-to-dropdown"]'); - await page.click('[data-testid="format-option-csv"]'); - - // Should show CSV options with default value - await expect(page.locator('[data-testid="page-numbers-input"]')).toBeVisible(); - await expect(page.locator('[data-testid="page-numbers-input"]')).toHaveValue('all'); - - // Should be able to change page numbers - await page.fill('[data-testid="page-numbers-input"]', '1,3,5-7'); - await expect(page.locator('[data-testid="page-numbers-input"]')).toHaveValue('1,3,5-7'); - - // Should show help text - await expect(page.locator('[data-testid="page-numbers-input"]')).toHaveAttribute('placeholder', /e\.g\.,.*all/); - - // Mathematical function should work too - await page.fill('[data-testid="page-numbers-input"]', '2n+1'); - await expect(page.locator('[data-testid="page-numbers-input"]')).toHaveValue('2n+1'); }); + test('should show progress indicators during conversion', async ({ page }) => { await page.setInputFiles('input[type="file"]', TEST_FILES.pdf); await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx index bb8c71279..f28b73ffb 100644 --- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -12,6 +12,7 @@ import { FileContextProvider } from '../../contexts/FileContext'; import { I18nextProvider } from 'react-i18next'; import i18n from '../../i18n/config'; import axios from 'axios'; +import { detectFileExtension } from '../../utils/fileUtils'; // Mock axios vi.mock('axios'); @@ -349,7 +350,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => { ]; testCases.forEach(({ filename, expected }) => { - const detected = result.current.detectFileExtension(filename); + const detected = detectFileExtension(filename); expect(detected).toBe(expected); }); }); diff --git a/frontend/src/tests/test-fixtures/sample.htm b/frontend/src/tests/test-fixtures/sample.htm new file mode 100644 index 000000000..83a5260a7 --- /dev/null +++ b/frontend/src/tests/test-fixtures/sample.htm @@ -0,0 +1,125 @@ + + + + + + Test HTML Document + + + +

Test HTML Document for Convert Tool

+ +

This is a test HTML file for testing the HTML to PDF conversion functionality. It contains various HTML elements to ensure proper conversion.

+ +

Text Formatting

+

This paragraph contains bold text, italic text, and inline code.

+ +
+

Important: This is a highlighted section that should be preserved in the PDF output.

+
+ +

Lists

+

Unordered List

+
    +
  • First item
  • +
  • Second item with a link
  • +
  • Third item
  • +
+ +

Ordered List

+
    +
  1. Primary point
  2. +
  3. Secondary point
  4. +
  5. Tertiary point
  6. +
+ +

Table

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Column 1Column 2Column 3
Data AData BData C
Test 1Test 2Test 3
Sample XSample YSample Z
+ +

Code Block

+
function testFunction() {
+    console.log("This is a test function");
+    return "Hello from HTML to PDF conversion";
+}
+ +

Final Notes

+

This HTML document should convert to a well-formatted PDF that preserves:

+
    +
  • Text formatting (bold, italic)
  • +
  • Headings and hierarchy
  • +
  • Tables with proper borders
  • +
  • Lists (ordered and unordered)
  • +
  • Code formatting
  • +
  • Basic CSS styling
  • +
+ +

Generated for Stirling PDF Convert Tool testing purposes.

+ + \ No newline at end of file diff --git a/frontend/src/utils/convertUtils.ts b/frontend/src/utils/convertUtils.ts index f43e1bf1b..9c058c5ce 100644 --- a/frontend/src/utils/convertUtils.ts +++ b/frontend/src/utils/convertUtils.ts @@ -49,4 +49,11 @@ export const isConversionSupported = (fromExtension: string, toExtension: string */ export const isImageFormat = (extension: string): boolean => { return ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bmp', 'webp', 'svg'].includes(extension.toLowerCase()); +}; + +/** + * Checks if the given extension is a web format + */ +export const isWebFormat = (extension: string): boolean => { + return ['html', 'zip'].includes(extension.toLowerCase()); }; \ No newline at end of file diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts index bff3f5b1c..b42d2f646 100644 --- a/frontend/src/utils/fileUtils.ts +++ b/frontend/src/utils/fileUtils.ts @@ -125,4 +125,51 @@ export function cleanupFileUrls(files: FileWithUrl[]): void { export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean { const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB return file.size > FILE_SIZE_LIMIT; +} + +/** + * Detects and normalizes file extension from filename + * @param filename - The filename to extract extension from + * @returns Normalized file extension in lowercase, empty string if no extension + */ +export function detectFileExtension(filename: string): string { + if (!filename || typeof filename !== 'string') return ''; + + const parts = filename.split('.'); + // If there's no extension (no dots or only one part), return empty string + if (parts.length <= 1) return ''; + + // Get the last part (extension) in lowercase + let extension = parts[parts.length - 1].toLowerCase(); + + // Normalize common extension variants + if (extension === 'jpeg') extension = 'jpg'; + + return extension; +} + +/** + * Gets the filename without extension + * @param filename - The filename to process + * @returns Filename without extension + */ +export function getFilenameWithoutExtension(filename: string): string { + if (!filename || typeof filename !== 'string') return ''; + + const parts = filename.split('.'); + if (parts.length <= 1) return filename; + + // Return all parts except the last one (extension) + return parts.slice(0, -1).join('.'); +} + +/** + * Creates a new filename with a different extension + * @param filename - Original filename + * @param newExtension - New extension (without dot) + * @returns New filename with the specified extension + */ +export function changeFileExtension(filename: string, newExtension: string): string { + const nameWithoutExt = getFilenameWithoutExtension(filename); + return `${nameWithoutExt}.${newExtension}`; } \ No newline at end of file diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index f0c28631a..35444035a 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -24,6 +24,11 @@ export async function generateThumbnailForFile(file: File): Promise