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 +}