=> {
+ 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
+
+ - Primary point
+ - Secondary point
+ - Tertiary point
+
+
+ Table
+
+
+
+ Column 1 |
+ Column 2 |
+ Column 3 |
+
+
+
+
+ Data A |
+ Data B |
+ Data C |
+
+
+ Test 1 |
+ Test 2 |
+ Test 3 |
+
+
+ Sample X |
+ Sample Y |
+ Sample 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