diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 8d6c49d1a..382a5bc51 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -1308,6 +1308,18 @@ includePageNumbersDesc = "Add page numbers to the generated PDF"
optimizeForEbookPdf = "Optimize for ebook readers"
optimizeForEbookPdfDesc = "Optimize the PDF for eBook reading (smaller file size, better rendering on eInk devices)"
+[convert.epubOptions]
+epubOptions = "PDF to eBook Options"
+epubOptionsDesc = "Options for converting PDF to EPUB/AZW3"
+detectChapters = "Detect chapters"
+detectChaptersDesc = "Detect headings that look like chapters and insert EPUB page breaks"
+targetDevice = "Target device"
+targetDeviceDesc = "Choose an output profile optimized for the reader device"
+outputFormat = "Output format"
+outputFormatDesc = "Choose the output format for the ebook"
+tabletPhone = "Tablet/Phone (with images)"
+kindleEink = "Kindle e-Ink (text optimized)"
+
[imageToPdf]
tags = "conversion,img,jpg,picture,photo"
diff --git a/frontend/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/src/core/components/tools/convert/ConvertSettings.tsx
index fb0b2f19d..68e690a97 100644
--- a/frontend/src/core/components/tools/convert/ConvertSettings.tsx
+++ b/frontend/src/core/components/tools/convert/ConvertSettings.tsx
@@ -20,6 +20,7 @@ import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSe
import ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings";
import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings";
import ConvertFromEbookSettings from "@app/components/tools/convert/ConvertFromEbookSettings";
+import ConvertToEpubSettings from "@app/components/tools/convert/ConvertToEpubSettings";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import {
FROM_FORMAT_OPTIONS,
@@ -163,6 +164,11 @@ const ConvertSettings = ({
includePageNumbers: false,
optimizeForEbook: false,
});
+ onParameterChange('epubOptions', {
+ detectChapters: true,
+ targetDevice: 'TABLET_PHONE_IMAGES',
+ outputFormat: 'EPUB',
+ });
onParameterChange('isSmartDetection', false);
onParameterChange('smartDetectionType', 'none');
};
@@ -423,6 +429,18 @@ const ConvertSettings = ({
>
)}
+ {/* PDF to EPUB/AZW3 options */}
+ {parameters.fromExtension === 'pdf' && ['epub', 'azw3'].includes(parameters.toExtension) && (
+ <>
+
+
+ >
+ )}
+
);
};
diff --git a/frontend/src/core/components/tools/convert/ConvertToEpubSettings.tsx b/frontend/src/core/components/tools/convert/ConvertToEpubSettings.tsx
new file mode 100644
index 000000000..b4b6564a5
--- /dev/null
+++ b/frontend/src/core/components/tools/convert/ConvertToEpubSettings.tsx
@@ -0,0 +1,103 @@
+import { Stack, Select, Checkbox } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
+
+interface ConvertToEpubSettingsProps {
+ parameters: ConvertParameters;
+ onParameterChange: (key: K, value: ConvertParameters[K]) => void;
+ disabled?: boolean;
+}
+
+const ConvertToEpubSettings = ({
+ parameters,
+ onParameterChange,
+ disabled = false
+}: ConvertToEpubSettingsProps) => {
+ const { t } = useTranslation();
+
+ const handleDetectChaptersChange = (value: boolean) => {
+ onParameterChange('epubOptions', {
+ detectChapters: value,
+ targetDevice: parameters.epubOptions?.targetDevice ?? 'TABLET_PHONE_IMAGES',
+ outputFormat: parameters.epubOptions?.outputFormat ?? parameters.toExtension === 'azw3' ? 'AZW3' : 'EPUB',
+ });
+ };
+
+ const handleTargetDeviceChange = (value: string | null) => {
+ if (value) {
+ onParameterChange('epubOptions', {
+ detectChapters: parameters.epubOptions?.detectChapters ?? true,
+ targetDevice: value,
+ outputFormat: parameters.epubOptions?.outputFormat ?? parameters.toExtension === 'azw3' ? 'AZW3' : 'EPUB',
+ });
+ }
+ };
+
+ const handleOutputFormatChange = (value: string | null) => {
+ if (value) {
+ onParameterChange('epubOptions', {
+ detectChapters: parameters.epubOptions?.detectChapters ?? true,
+ targetDevice: parameters.epubOptions?.targetDevice ?? 'TABLET_PHONE_IMAGES',
+ outputFormat: value,
+ });
+ }
+ };
+
+ // Initialize epubOptions if not present, set output format based on toExtension
+ const epubOptions = parameters.epubOptions || {
+ detectChapters: true,
+ targetDevice: 'TABLET_PHONE_IMAGES',
+ outputFormat: parameters.toExtension === 'azw3' ? 'AZW3' : 'EPUB',
+ };
+
+ // Sync output format with selected target extension if not manually set
+ if (parameters.toExtension === 'azw3' && epubOptions.outputFormat !== 'AZW3') {
+ handleOutputFormatChange('AZW3');
+ } else if (parameters.toExtension === 'epub' && epubOptions.outputFormat !== 'EPUB') {
+ handleOutputFormatChange('EPUB');
+ }
+
+ return (
+
+ handleDetectChaptersChange(event.currentTarget.checked)}
+ disabled={disabled}
+ />
+
+
+
+
+
+ );
+};
+
+export default ConvertToEpubSettings;
diff --git a/frontend/src/core/constants/convertConstants.ts b/frontend/src/core/constants/convertConstants.ts
index d6f9bf6f2..6370cc6fa 100644
--- a/frontend/src/core/constants/convertConstants.ts
+++ b/frontend/src/core/constants/convertConstants.ts
@@ -37,6 +37,7 @@ export const CONVERSION_ENDPOINTS = {
'cbr-pdf': '/api/v1/convert/cbr/pdf',
'pdf-cbr': '/api/v1/convert/pdf/cbr',
'ebook-pdf': '/api/v1/convert/ebook/pdf',
+ 'pdf-epub': '/api/v1/convert/pdf/epub',
'pdf-text-editor': '/api/v1/convert/pdf/text-editor',
'text-editor-pdf': '/api/v1/convert/text-editor/pdf'
} as const;
@@ -61,6 +62,7 @@ export const ENDPOINT_NAMES = {
'ebook-pdf': 'ebook-to-pdf',
'cbr-pdf': 'cbr-to-pdf',
'pdf-cbr': 'pdf-to-cbr',
+ 'pdf-epub': 'pdf-to-epub',
'pdf-text-editor': 'pdf-to-text-editor',
'text-editor-pdf': 'text-editor-to-pdf'
} as const;
@@ -124,13 +126,15 @@ export const TO_FORMAT_OPTIONS = [
{ value: 'webp', label: 'WEBP', group: 'Image' },
{ value: 'html', label: 'HTML', group: 'Web' },
{ value: 'xml', label: 'XML', group: 'Web' },
+ { value: 'epub', label: 'EPUB', group: 'eBook' },
+ { value: 'azw3', label: 'AZW3', group: 'eBook' },
];
// Conversion matrix - what each source format can convert to
export const CONVERSION_MATRIX: Record = {
'any': ['pdf'], // Mixed files always convert to PDF
'image': ['pdf'], // Multiple images always convert to PDF
- 'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa', 'cbz', 'cbr'],
+ 'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa', 'cbz', 'cbr', 'epub', 'azw3'],
'cbz': ['pdf'],
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'],
@@ -159,7 +163,8 @@ export const EXTENSION_TO_ENDPOINT: Record> = {
'html': 'pdf-to-html', 'xml': 'pdf-to-xml',
'pdfa': 'pdf-to-pdfa',
'cbr': 'pdf-to-cbr',
- 'cbz': 'pdf-to-cbz'
+ 'cbz': 'pdf-to-cbz',
+ 'epub': 'pdf-to-epub', 'azw3': 'pdf-to-epub'
},
'cbz': { 'pdf': 'cbz-to-pdf' },
'docx': { 'pdf': 'file-to-pdf' }, 'doc': { 'pdf': 'file-to-pdf' }, 'odt': { 'pdf': 'file-to-pdf' },
diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx
index 899eef5cb..ee142099b 100644
--- a/frontend/src/core/data/useTranslatedToolRegistry.tsx
+++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx
@@ -892,6 +892,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
"pdf-to-markdown",
"pdf-to-pdfa",
"eml-to-pdf",
+ "pdf-to-epub",
],
operationConfig: convertOperationConfig,
diff --git a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts
index 335435dd4..182c0cd68 100644
--- a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts
+++ b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts
@@ -23,6 +23,8 @@ export const shouldProcessFilesSeparately = (
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) ||
// PDF to CBR conversions (each PDF should generate its own archive)
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbr') ||
+ // PDF to EPUB/AZW3 conversions (each PDF should generate its own ebook)
+ (parameters.fromExtension === 'pdf' && ['epub', 'azw3'].includes(parameters.toExtension)) ||
// PDF to office format conversions (each PDF should generate its own office file)
(parameters.fromExtension === 'pdf' && isOfficeFormat(parameters.toExtension)) ||
// Office files to PDF conversions (each file should be processed separately via LibreOffice)
@@ -42,7 +44,7 @@ export const shouldProcessFilesSeparately = (
// Static function that can be used by both the hook and automation executor
export const buildConvertFormData = (parameters: ConvertParameters, selectedFiles: File[]): FormData => {
const formData = new FormData();
- const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions, cbrOptions, pdfToCbrOptions, cbzOptions, cbzOutputOptions, ebookOptions } = parameters;
+ const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions, cbrOptions, pdfToCbrOptions, cbzOptions, cbzOutputOptions, ebookOptions, epubOptions } = parameters;
selectedFiles.forEach(file => {
formData.append("fileInput", file);
@@ -88,6 +90,10 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
formData.append("includeTableOfContents", (ebookOptions?.includeTableOfContents ?? false).toString());
formData.append("includePageNumbers", (ebookOptions?.includePageNumbers ?? false).toString());
formData.append("optimizeForEbook", (ebookOptions?.optimizeForEbook ?? false).toString());
+ } else if (fromExtension === 'pdf' && ['epub', 'azw3'].includes(toExtension)) {
+ formData.append("detectChapters", (epubOptions?.detectChapters ?? true).toString());
+ formData.append("targetDevice", epubOptions?.targetDevice ?? 'TABLET_PHONE_IMAGES');
+ formData.append("outputFormat", epubOptions?.outputFormat ?? (toExtension === 'azw3' ? 'AZW3' : 'EPUB'));
}
return formData;
diff --git a/frontend/src/core/hooks/tools/convert/useConvertParameters.ts b/frontend/src/core/hooks/tools/convert/useConvertParameters.ts
index e5cd6d6c6..59557465f 100644
--- a/frontend/src/core/hooks/tools/convert/useConvertParameters.ts
+++ b/frontend/src/core/hooks/tools/convert/useConvertParameters.ts
@@ -54,6 +54,11 @@ export interface ConvertParameters extends BaseParameters {
includePageNumbers: boolean;
optimizeForEbook: boolean;
};
+ epubOptions?: {
+ detectChapters: boolean;
+ targetDevice: string;
+ outputFormat: string;
+ };
isSmartDetection: boolean;
smartDetectionType: 'mixed' | 'images' | 'web' | 'none';
}
@@ -105,6 +110,11 @@ export const defaultParameters: ConvertParameters = {
includePageNumbers: false,
optimizeForEbook: false,
},
+ epubOptions: {
+ detectChapters: true,
+ targetDevice: 'TABLET_PHONE_IMAGES',
+ outputFormat: 'EPUB',
+ },
isSmartDetection: false,
smartDetectionType: 'none',
};
diff --git a/frontend/src/core/tests/helpers/conversionEndpointDiscovery.ts b/frontend/src/core/tests/helpers/conversionEndpointDiscovery.ts
index 3e6c93a07..5bb26b48f 100644
--- a/frontend/src/core/tests/helpers/conversionEndpointDiscovery.ts
+++ b/frontend/src/core/tests/helpers/conversionEndpointDiscovery.ts
@@ -1,6 +1,6 @@
/**
* Conversion Endpoint Discovery for E2E Testing
- *
+ *
* Uses the backend's endpoint configuration API to discover available conversions
*/
@@ -121,6 +121,13 @@ const ALL_CONVERSION_ENDPOINTS: ConversionEndpoint[] = [
description: 'Convert email (EML) to PDF',
apiPath: '/api/v1/convert/eml/pdf'
},
+ {
+ endpoint: 'pdf-to-epub',
+ fromFormat: 'pdf',
+ toFormat: 'epub',
+ description: 'Convert PDF to EPUB/AZW3',
+ apiPath: '/api/v1/convert/pdf/epub'
+ },
{
endpoint: 'eml-to-pdf', // MSG uses same endpoint as EML
fromFormat: 'msg',
@@ -145,8 +152,8 @@ export class ConversionEndpointDiscovery {
*/
async getAvailableConversions(): Promise {
const endpointStatuses = await this.getEndpointStatuses();
-
- return ALL_CONVERSION_ENDPOINTS.filter(conversion =>
+
+ return ALL_CONVERSION_ENDPOINTS.filter(conversion =>
endpointStatuses.get(conversion.endpoint) === true
);
}
@@ -156,8 +163,8 @@ export class ConversionEndpointDiscovery {
*/
async getUnavailableConversions(): Promise {
const endpointStatuses = await this.getEndpointStatuses();
-
- return ALL_CONVERSION_ENDPOINTS.filter(conversion =>
+
+ return ALL_CONVERSION_ENDPOINTS.filter(conversion =>
endpointStatuses.get(conversion.endpoint) === false
);
}
@@ -175,16 +182,16 @@ export class ConversionEndpointDiscovery {
*/
async getConversionsByFormat(): Promise> {
const availableConversions = await this.getAvailableConversions();
-
+
const grouped: Record = {};
-
+
availableConversions.forEach(conversion => {
if (!grouped[conversion.fromFormat]) {
grouped[conversion.fromFormat] = [];
}
grouped[conversion.fromFormat].push(conversion);
});
-
+
return grouped;
}
@@ -193,7 +200,7 @@ export class ConversionEndpointDiscovery {
*/
async getSupportedTargetFormats(fromFormat: string): Promise {
const availableConversions = await this.getAvailableConversions();
-
+
return availableConversions
.filter(conversion => conversion.fromFormat === fromFormat)
.map(conversion => conversion.toFormat);
@@ -204,11 +211,11 @@ export class ConversionEndpointDiscovery {
*/
async getSupportedSourceFormats(): Promise {
const availableConversions = await this.getAvailableConversions();
-
+
const sourceFormats = new Set(
availableConversions.map(conversion => conversion.fromFormat)
);
-
+
return Array.from(sourceFormats);
}
@@ -224,33 +231,33 @@ export class ConversionEndpointDiscovery {
try {
const endpointNames = ALL_CONVERSION_ENDPOINTS.map(conv => conv.endpoint);
const endpointsParam = endpointNames.join(',');
-
+
const response = await fetch(
`${this.baseUrl}/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`
);
-
+
if (!response.ok) {
throw new Error(`Failed to fetch endpoint statuses: ${response.status} ${response.statusText}`);
}
-
+
const statusMap: Record = await response.json();
-
+
// Convert to Map and cache
this.cache = new Map(Object.entries(statusMap));
this.cacheExpiry = Date.now() + this.CACHE_DURATION;
-
+
console.log(`Retrieved status for ${Object.keys(statusMap).length} conversion endpoints`);
return this.cache;
-
+
} catch (error) {
console.error('Failed to get endpoint statuses:', error);
-
+
// Fallback: assume all endpoints are disabled
const fallbackMap = new Map();
ALL_CONVERSION_ENDPOINTS.forEach(conv => {
fallbackMap.set(conv.endpoint, false);
});
-
+
return fallbackMap;
}
}
@@ -289,15 +296,15 @@ export const conversionDiscovery = new ConversionEndpointDiscovery();
export function useConversionEndpoints() {
const endpointNames = ALL_CONVERSION_ENDPOINTS.map(conv => conv.endpoint);
const { endpointStatus, loading, error, refetch } = useMultipleEndpointsEnabled(endpointNames);
-
+
const availableConversions = ALL_CONVERSION_ENDPOINTS.filter(
conv => endpointStatus[conv.endpoint] === true
);
-
+
const unavailableConversions = ALL_CONVERSION_ENDPOINTS.filter(
conv => endpointStatus[conv.endpoint] === false
);
-
+
return {
availableConversions,
unavailableConversions,
@@ -308,4 +315,4 @@ export function useConversionEndpoints() {
refetch,
isConversionAvailable: (endpoint: string) => endpointStatus[endpoint] === true
};
-}
\ No newline at end of file
+}