Added convert tool

This commit is contained in:
Connor Yoh 2025-06-26 17:00:45 +01:00
parent 29916d85b1
commit 622daba780
3 changed files with 479 additions and 13 deletions

View File

@ -347,6 +347,10 @@
"title": "Rotate",
"desc": "Easily rotate your PDFs."
},
"convert": {
"title": "Convert",
"desc": "Convert files between different formats",
},
"imageToPdf": {
"title": "Image to PDF",
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
@ -643,6 +647,44 @@
"selectAngle": "Select rotation angle (in multiples of 90 degrees):",
"submit": "Rotate"
},
"convert":{
"title": "Convert",
"desc": "Convert files between different formats",
"convertFrom": "Convert from",
"convertTo": "Convert to",
"outputOptions": "Output Options",
"pdfOptions": "PDF Options",
"imageOptions": "Image Options",
"colorType": "Color Type",
"color": "Color",
"greyscale": "Greyscale",
"blackwhite": "Black & White",
"dpi": "DPI",
"output": "Output",
"single": "Single merged image",
"multiple": "Multiple images (one per page)",
"fileFormat": "File Format",
"wordDoc": "Word Document",
"wordDocExt": "Word Document (.docx)",
"odtExt": "OpenDocument Text (.odt)",
"pptExt": "PowerPoint (.pptx)",
"odpExt": "OpenDocument Presentation (.odp)",
"txtExt": "Plain Text (.txt)",
"rtfExt": "Rich Text Format (.rtf)",
"selectedFiles": "Selected files",
"noFileSelected": "No file selected. Use the file panel to add files.",
"convertFiles": "Convert Files",
"converting": "Converting...",
"downloadConverted": "Download Converted File",
"errorNoFiles": "Please select at least one file to convert.",
"errorNoFormat": "Please select both source and target formats.",
"errorNotSupported": "Conversion from {{from}} to {{to}} is not supported.",
"images": "Images",
"officeDocs": "Office Documents (Word, Excel, PowerPoint)",
"imagesExt": "Images (JPG, PNG, etc.)",
"markdown": "Markdown",
"textRtf": "Text/RTF"
},
"imageToPdf": {
"tags": "conversion,img,jpg,picture,photo"
},
@ -1572,18 +1614,6 @@
"pageEditor": "Page Editor",
"fileManager": "File Manager"
},
"fileManager": {
"dragDrop": "Drag & Drop files here",
"clickToUpload": "Click to upload files",
"selectedFiles": "Selected Files",
"clearAll": "Clear All",
"storage": "Storage",
"filesStored": "files stored",
"storageError": "Storage error occurred",
"storageLow": "Storage is running low. Consider removing old files.",
"uploadError": "Failed to upload some files.",
"supportMessage": "Powered by browser database storage for unlimited capacity"
},
"pageEditor": {
"title": "Page Editor",
"save": "Save Changes",
@ -1655,7 +1685,16 @@
"failedToLoad": "Failed to load file to active set.",
"storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.",
"clearAll": "Clear All",
"reloadFiles": "Reload Files"
"reloadFiles": "Reload Files",
"dragDrop": "Drag & Drop files here",
"clickToUpload": "Click to upload files",
"selectedFiles": "Selected Files",
"storage": "Storage",
"filesStored": "files stored",
"storageError": "Storage error occurred",
"storageLow": "Storage is running low. Consider removing old files.",
"supportMessage": "Powered by browser database storage for unlimited capacity",
"noFileSelected": "No files selected"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",

View File

@ -7,6 +7,7 @@ import { fileStorage } from "../services/fileStorage";
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import { Group, Paper, Box, Button, useMantineTheme, Container } from "@mantine/core";
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
import rainbowStyles from '../styles/rainbow.module.css';
@ -24,6 +25,7 @@ import CompressPdfPanel from "../tools/Compress";
import MergePdfPanel from "../tools/Merge";
import ToolRenderer from "../components/tools/ToolRenderer";
import QuickAccessBar from "../components/shared/QuickAccessBar";
import ConvertPanel from "../tools/Convert";
type ToolRegistryEntry = {
icon: React.ReactNode;
@ -41,6 +43,7 @@ const baseToolRegistry = {
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "viewer" },
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "viewer" },
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "fileManager" },
convert: { icon: <SyncAltIcon />, component: ConvertPanel, view: "fileManager" },
};
export default function HomePage() {
@ -114,6 +117,7 @@ export default function HomePage() {
split: { ...baseToolRegistry.split, name: t("home.split.title", "Split PDF") },
compress: { ...baseToolRegistry.compress, name: t("home.compressPdfs.title", "Compress PDF") },
merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") },
convert: { ...baseToolRegistry.convert, name: t("home.convert.title", "Convert") },
};
// Handle tool selection

View File

@ -0,0 +1,423 @@
import React, { useState, useEffect } from "react";
import {
Paper,
Button,
Stack,
Text,
Group,
Alert,
Divider,
Select,
TextInput,
Checkbox,
NumberInput,
Center,
} from "@mantine/core";
import { ArrowDownward } from "@mui/icons-material";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FileWithUrl } from "../types/file";
import { fileStorage } from "../services/fileStorage";
export interface ConvertPanelProps {
files: FileWithUrl[];
setDownloadUrl: (url: string) => void;
params: {
fromFormat: string;
toFormat: string;
imageOptions?: {
colorType: string;
dpi: number;
singleOrMultiple: string;
};
officeOptions?: {
outputFormat: string;
};
};
updateParams: (newParams: Partial<ConvertPanelProps["params"]>) => void;
}
const ConvertPanel: React.FC<ConvertPanelProps> = ({ files, setDownloadUrl, params, updateParams }) => {
const { t } = useTranslation();
const [downloadUrl, setLocalDownloadUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [fromFormat, setFromFormat] = useState(params.fromFormat || "");
const [toFormat, setToFormat] = useState(params.toFormat || "");
const [colorType, setColorType] = useState(params.imageOptions?.colorType || "color");
const [dpi, setDpi] = useState(params.imageOptions?.dpi || 300);
const [singleOrMultiple, setSingleOrMultiple] = useState(params.imageOptions?.singleOrMultiple || "multiple");
const [outputFormat, setOutputFormat] = useState(params.officeOptions?.outputFormat || "");
useEffect(() => {
if (files.length > 0 && !fromFormat) {
const firstFile = files[0];
const detectedFormat = detectFileFormat(firstFile.name);
setFromFormat(detectedFormat);
updateParams({ fromFormat: detectedFormat });
}
}, [files, fromFormat]);
const detectFileFormat = (filename: string): string => {
const extension = filename.split('.').pop()?.toLowerCase();
switch (extension) {
case 'pdf': return 'pdf';
case 'doc': case 'docx': return 'office';
case 'xls': case 'xlsx': return 'office';
case 'ppt': case 'pptx': return 'office';
case 'odt': case 'ods': case 'odp': return 'office';
case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': case 'tiff': case 'webp': return 'image';
case 'html': case 'htm': return 'html';
case 'md': return 'markdown';
case 'txt': case 'rtf': return 'text';
default: return 'unknown';
}
};
const getAvailableToFormats = (from: string): string[] => {
switch (from) {
case 'pdf':
return ['image', 'office-word', 'office-presentation', 'office-text', 'html', 'xml'];
case 'office':
return ['pdf'];
case 'image':
return ['pdf'];
case 'html':
return ['pdf'];
case 'markdown':
return ['pdf'];
case 'text':
return ['pdf'];
default:
return [];
}
};
const getApiEndpoint = (from: string, to: string): string => {
if (from === 'office' && to === 'pdf') {
return '/api/v1/convert/file/pdf';
} else if (from === 'pdf' && to === 'image') {
return '/api/v1/convert/pdf/img';
} else if (from === 'image' && to === 'pdf') {
return '/api/v1/convert/img/pdf';
} else if (from === 'pdf' && to === 'office-word') {
return '/api/v1/convert/pdf/word';
} else if (from === 'pdf' && to === 'office-presentation') {
return '/api/v1/convert/pdf/presentation';
} else if (from === 'pdf' && to === 'office-text') {
return '/api/v1/convert/pdf/text';
} else if (from === 'pdf' && to === 'html') {
return '/api/v1/convert/pdf/html';
} else if (from === 'pdf' && to === 'xml') {
return '/api/v1/convert/pdf/xml';
} else if (from === 'html' && to === 'pdf') {
return '/api/v1/convert/html/pdf';
} else if (from === 'markdown' && to === 'pdf') {
return '/api/v1/convert/markdown/pdf';
}
return '';
};
const handleConvert = async () => {
if (files.length === 0) {
setErrorMessage(t("convert.errorNoFiles", "Please select at least one file to convert."));
return;
}
if (!fromFormat || !toFormat) {
setErrorMessage(t("convert.errorNoFormat", "Please select both source and target formats."));
return;
}
const endpoint = getApiEndpoint(fromFormat, toFormat);
if (!endpoint) {
setErrorMessage(
t("convert.errorNotSupported", { from: fromFormat, to: toFormat, defaultValue: `Conversion from ${fromFormat} to ${toFormat} is not supported.` })
);
return;
}
const formData = new FormData();
// Handle IndexedDB files
for (const file of files) {
if (!file.id) {
console.warn("File without ID found, skipping:", file.name);
continue;
}
const storedFile = await fileStorage.getFile(file.id);
if (!storedFile) {
console.warn("Stored file not found in IndexedDB for ID:", file.id);
continue;
}
const blob = new Blob([storedFile.data], { type: storedFile.type });
const actualFile = new File([blob], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified,
});
formData.append("fileInput", actualFile);
}
// Add conversion-specific parameters
if (toFormat === 'image') {
formData.append("imageFormat", "png");
formData.append("colorType", colorType);
formData.append("dpi", dpi.toString());
formData.append("singleOrMultiple", singleOrMultiple);
} else if (fromFormat === 'pdf' && toFormat.startsWith('office')) {
if (toFormat === 'office-word') {
formData.append("outputFormat", outputFormat || "docx");
} else if (toFormat === 'office-presentation') {
formData.append("outputFormat", outputFormat || "pptx");
} else if (toFormat === 'office-text') {
formData.append("outputFormat", outputFormat || "txt");
}
} else if (fromFormat === 'image' && toFormat === 'pdf') {
formData.append("fitOption", "fillPage");
formData.append("colorType", colorType);
formData.append("autoRotate", "true");
}
setIsLoading(true);
setErrorMessage(null);
try {
console.log("Converting files from", fromFormat, "to", toFormat, "using endpoint:", endpoint);
console.log("Form data:", Array.from(formData.entries()).map(([key, value]) => `${key}: ${value}`));
const response = await fetch(endpoint, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Conversion failed: ${errorText}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setDownloadUrl(url);
setLocalDownloadUrl(url);
} catch (error: any) {
setErrorMessage(error.message || "Unknown error occurred.");
} finally {
setIsLoading(false);
}
};
const handleFromFormatChange = (value: string | null) => {
if (value) {
setFromFormat(value);
setToFormat("");
// Reset all format-specific options when source format changes
setColorType("color");
setDpi(300);
setSingleOrMultiple("multiple");
setOutputFormat("");
updateParams({
fromFormat: value,
toFormat: "",
imageOptions: { colorType: "color", dpi: 300, singleOrMultiple: "multiple" },
officeOptions: { outputFormat: "" }
});
}
};
const handleToFormatChange = (value: string | null) => {
if (value) {
setToFormat(value);
// Reset format-specific options when target format changes
setColorType("color");
setDpi(300);
setSingleOrMultiple("multiple");
setOutputFormat("");
updateParams({
fromFormat,
toFormat: value,
imageOptions: { colorType: "color", dpi: 300, singleOrMultiple: "multiple" },
officeOptions: { outputFormat: "" }
});
}
};
return (
<Stack>
<Text size="sm">
{t("convert.desc", "Convert files between different formats")}
</Text>
<Divider my="sm" />
<Stack gap="md">
<Stack gap="sm" align="center">
<div style={{ width: '100%' }}>
<Text size="sm" fw={500} mb="xs">
{t("convert.convertFrom", "Convert from")}:
</Text>
<Select
value={fromFormat}
onChange={handleFromFormatChange}
data={[
{ value: 'pdf', label: 'PDF' },
{ value: 'office', label: t("convert.officeDocs", "Office Documents (Word, Excel, PowerPoint)") },
{ value: 'image', label: t("convert.imagesExt", "Images (JPG, PNG, etc.)") },
{ value: 'html', label: 'HTML' },
{ value: 'markdown', label: t("convert.markdown", "Markdown") },
{ value: 'text', label: t("convert.textRtf", "Text/RTF") },
]}
/>
</div>
<div style={{ width: '100%' }}>
<Text size="sm" fw={500} mb="xs">
{t("convert.convertTo", "Convert to")}:
</Text>
<Select
value={toFormat}
onChange={handleToFormatChange}
data={getAvailableToFormats(fromFormat).map(format => ({
value: format,
label: format === 'office-word' ? t("convert.wordDoc", "Word Document") :
format === 'office-presentation' ? t("PowerPoint Presentation", "PowerPoint Presentation") :
format === 'office-text' ? t("convert.textRtf", "Text/RTF") :
format === 'image' ? t("convert.images", "Images") :
format === 'pdf' ? 'PDF' :
format.charAt(0).toUpperCase() + format.slice(1)
}))}
disabled={!fromFormat}
/>
</div>
</Stack>
{(toFormat === 'image' || (fromFormat === 'pdf' && toFormat?.startsWith('office')) || (fromFormat === 'image' && toFormat === 'pdf')) && (
<Divider />
)}
{toFormat === 'image' && (
<Stack gap="sm">
<Text size="sm" fw={500}>{t("convert.imageOptions", "Image Options")}:</Text>
<Group grow>
<Select
label={t("convert.colorType", "Color Type")}
value={colorType}
onChange={(val) => val && setColorType(val)}
data={[
{ value: 'color', label: t("convert.color", "Color") },
{ value: 'greyscale', label: t("convert.greyscale", "Greyscale") },
{ value: 'blackwhite', label: t("convert.blackwhite", "Black & White") },
]}
/>
<NumberInput
label="DPI"
value={dpi}
onChange={(val) => typeof val === 'number' && setDpi(val)}
min={72}
max={600}
step={1}
/>
</Group>
<Select
label={t("convert.output", "Output")}
value={singleOrMultiple}
onChange={(val) => val && setSingleOrMultiple(val)}
data={[
{ value: 'single', label: t("convert.single") },
{ value: 'multiple', label: t("convert.multiple") },
]}
/>
</Stack>
)}
{fromFormat === 'pdf' && toFormat?.startsWith('office') && (
<Stack gap="sm">
<Text size="sm" fw={500}>{t("convert.outputOptions", "Output Options")}:</Text>
<Select
label={t("convert.fileFormat")}
value={outputFormat}
onChange={(val) => val && setOutputFormat(val)}
data={
toFormat === 'office-word' ? [
{ value: 'docx', label: t("convert.wordDocExt") },
{ value: 'odt', label: t("convert.odtExt") },
] :
toFormat === 'office-presentation' ? [
{ value: 'pptx', label: t("convert.pptExt") },
{ value: 'odp', label: t("convert.odpExt") },
] :
toFormat === 'office-text' ? [
{ value: 'txt', label: t("convert.txtExt") },
{ value: 'rtf', label: t("convert.rtfExt") },
] : []
}
/>
</Stack>
)}
{fromFormat === 'image' && toFormat === 'pdf' && (
<Stack gap="sm">
<Text size="sm" fw={500}>{t("convert.pdfOptions", "PDF Options")}:</Text>
<Select
label={t("convert.colorType")}
value={colorType}
onChange={(val) => val && setColorType(val)}
data={[
{ value: 'color', label: t("convert.color") },
{ value: 'greyscale', label: t("convert.greyscale") },
{ value: 'blackwhite', label: t("convert.blackwhite") },
]}
/>
</Stack>
)}
<Divider my="sm" />
<div>
<Text size="sm" fw={500} mb="xs">
{t("convert.selectedFiles", "Selected files")}: ({files.length}):
</Text>
<Stack gap={4}>
{files.map((file, index) => (
<Group key={index} gap="xs">
<Text size="sm">{file.name}</Text>
</Group>
))}
{files.length === 0 && (
<Text size="sm" c="dimmed">
{t("convert.noFileSelected", "No files selected for conversion. Please add files to convert.")}
</Text>
)}
</Stack>
</div>
<Button
onClick={handleConvert}
loading={isLoading}
disabled={files.length === 0 || !fromFormat || !toFormat || isLoading}
size="md"
>
{isLoading ? t("convert.converting", "Converting...") : t("convert.convertFiles", "Convert Files")}
</Button>
{errorMessage && (
<Alert color="red">
{errorMessage}
</Alert>
)}
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
download
color="green"
variant="light"
size="md"
>
{t("convert.downloadConverted", "Download Converted File")} <ArrowDownward />
</Button>
)}
</Stack>
</Stack>
);
};
export default ConvertPanel;