html to pdf

This commit is contained in:
Connor Yoh 2025-07-31 13:47:48 +01:00
parent 8c91113125
commit 8da72af478
13 changed files with 453 additions and 239 deletions

View File

@ -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": {

View File

@ -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'}`);

View File

@ -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' && (
<>

View File

@ -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' }

View File

@ -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,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);

View File

@ -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');
});
});
});

View File

@ -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,
};
};

View File

@ -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 });

View File

@ -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);
});
});

View 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>

View File

@ -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());
};

View File

@ -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}`;
}

View File

@ -24,6 +24,11 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
console.log('Skipping thumbnail generation for large file:', file.name);
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);