mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 18:45:21 +00:00
html to pdf
This commit is contained in:
parent
8c91113125
commit
8da72af478
@ -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": {
|
||||
|
@ -224,16 +224,13 @@ 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
|
||||
if (validation.isValid && validation.containsPDFs) {
|
||||
// ZIP contains PDFs - extract them
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: file.name,
|
||||
@ -293,6 +290,11 @@ const FileEditor = ({
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
// 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'}`);
|
||||
setZipExtractionProgress({
|
||||
|
@ -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')) && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="sm" data-testid="html-options-section">
|
||||
<Text size="sm" fw={500} data-testid="html-options-title">{t("convert.htmlOptions", "HTML Options")}:</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>{t("convert.zoomLevel", "Zoom Level")}:</Text>
|
||||
<NumberInput
|
||||
value={parameters.htmlOptions.zoomLevel}
|
||||
onChange={(value) => 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"
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.htmlOptions.zoomLevel}
|
||||
onChange={(value) => onParameterChange('htmlOptions', { ...parameters.htmlOptions, zoomLevel: value })}
|
||||
min={0.1}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
disabled={disabled}
|
||||
data-testid="zoom-level-slider"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* EML specific options */}
|
||||
{parameters.fromExtension === 'eml' && parameters.toExtension === 'pdf' && (
|
||||
<>
|
||||
|
@ -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<string, string[]> = {
|
||||
'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<string, Record<string, string>> = {
|
||||
'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' }
|
||||
|
@ -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<string[]> => {
|
||||
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,24 +298,16 @@ 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) {
|
||||
console.error(`Error converting file ${file.name}:`, error);
|
||||
@ -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);
|
||||
// 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);
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,43 +486,8 @@ 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);
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
125
frontend/src/tests/test-fixtures/sample.htm
Normal file
125
frontend/src/tests/test-fixtures/sample.htm
Normal file
@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test HTML Document</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #34495e;
|
||||
margin-top: 30px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
font-weight: bold;
|
||||
}
|
||||
.highlight {
|
||||
background-color: #fff3cd;
|
||||
padding: 10px;
|
||||
border-left: 4px solid #ffc107;
|
||||
margin: 20px 0;
|
||||
}
|
||||
code {
|
||||
background-color: #f8f9fa;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test HTML Document for Convert Tool</h1>
|
||||
|
||||
<p>This is a <strong>test HTML file</strong> for testing the HTML to PDF conversion functionality. It contains various HTML elements to ensure proper conversion.</p>
|
||||
|
||||
<h2>Text Formatting</h2>
|
||||
<p>This paragraph contains <strong>bold text</strong>, <em>italic text</em>, and <code>inline code</code>.</p>
|
||||
|
||||
<div class="highlight">
|
||||
<p><strong>Important:</strong> This is a highlighted section that should be preserved in the PDF output.</p>
|
||||
</div>
|
||||
|
||||
<h2>Lists</h2>
|
||||
<h3>Unordered List</h3>
|
||||
<ul>
|
||||
<li>First item</li>
|
||||
<li>Second item with <a href="https://example.com">a link</a></li>
|
||||
<li>Third item</li>
|
||||
</ul>
|
||||
|
||||
<h3>Ordered List</h3>
|
||||
<ol>
|
||||
<li>Primary point</li>
|
||||
<li>Secondary point</li>
|
||||
<li>Tertiary point</li>
|
||||
</ol>
|
||||
|
||||
<h2>Table</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Column 1</th>
|
||||
<th>Column 2</th>
|
||||
<th>Column 3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Data A</td>
|
||||
<td>Data B</td>
|
||||
<td>Data C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Test 1</td>
|
||||
<td>Test 2</td>
|
||||
<td>Test 3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sample X</td>
|
||||
<td>Sample Y</td>
|
||||
<td>Sample Z</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Code Block</h2>
|
||||
<pre><code>function testFunction() {
|
||||
console.log("This is a test function");
|
||||
return "Hello from HTML to PDF conversion";
|
||||
}</code></pre>
|
||||
|
||||
<h2>Final Notes</h2>
|
||||
<p>This HTML document should convert to a well-formatted PDF that preserves:</p>
|
||||
<ul>
|
||||
<li>Text formatting (bold, italic)</li>
|
||||
<li>Headings and hierarchy</li>
|
||||
<li>Tables with proper borders</li>
|
||||
<li>Lists (ordered and unordered)</li>
|
||||
<li>Code formatting</li>
|
||||
<li>Basic CSS styling</li>
|
||||
</ul>
|
||||
|
||||
<p><small>Generated for Stirling PDF Convert Tool testing purposes.</small></p>
|
||||
</body>
|
||||
</html>
|
@ -50,3 +50,10 @@ 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());
|
||||
};
|
@ -126,3 +126,50 @@ 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}`;
|
||||
}
|
@ -25,6 +25,11 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('application/pdf')) {
|
||||
console.warn('File is not a PDF, skipping thumbnail generation:', file.name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Generating thumbnail for', file.name);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user