[V2] feat(convert): add eBook (EPUB, MOBI, AZW3, FB2) to PDF conversion options and UI (#5291)

# Description of Changes

This pull request adds support for converting eBook formats (EPUB, MOBI,
AZW3, FB2) to PDF, including a new set of user-configurable options for
the conversion process. It introduces frontend UI components, updates
configuration and constants, and ensures that the backend can recognize
and handle these new formats and options.

**eBook to PDF Conversion Support**

- Added support for converting eBook formats (`epub`, `mobi`, `azw3`,
`fb2`) to PDF, including updates to conversion endpoints, extension
mappings, and conversion matrices in `convertConstants.ts` to route
these conversions through the new `ebook-to-pdf` endpoint.
- Updated the file processing logic so that eBook-to-PDF conversions are
processed separately for each file, similar to web file conversions.

**Frontend: User Options for eBook Conversion**

- Added a new UI component `ConvertFromEbookSettings.tsx` that allows
users to configure options for eBook-to-PDF conversion: embedding all
fonts, including a table of contents, adding page numbers, and
optimizing for eBook readers. This component is conditionally rendered
in the conversion settings when an eBook format is selected as the
source and PDF as the target.
- Integrated the new eBook options into the conversion parameters and
ensured they are initialized/reset appropriately in the conversion
settings.
**Form Data and Backend Integration**

- Modified the form data builder to append the new eBook options to the
request payload when performing eBook-to-PDF conversions, ensuring these
settings are sent to the backend.

**Localization and Configuration**

- Added localization strings for all new eBook conversion options in the
English translation file, providing user-friendly labels and
descriptions in the UI.
- Updated backend configuration logic to recognize `Calibre` and
`FFmpeg` as tool groups, ensuring correct enablement/disablement
behavior for these tools.

<img width="366" height="997" alt="image"
src="https://github.com/user-attachments/assets/44d3308c-ea49-4874-8f5e-c7d617a37489"
/>

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs 2025-12-25 15:58:10 +01:00 committed by GitHub
parent 43a5f72b01
commit 1318604f32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 165 additions and 10 deletions

View File

@ -156,19 +156,19 @@ public class EndpointConfiguration {
return false;
}
// Rule 2: For tool groups, they're enabled unless explicitly disabled (handled above)
if (isToolGroup(group)) {
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
return true;
}
// Rule 3: For functional groups, check if all endpoints are enabled
Set<String> endpoints = endpointGroups.get(group);
if (endpoints == null || endpoints.isEmpty()) {
log.debug("isGroupEnabled('{}') -> false (no endpoints)", group);
return false;
}
// Rule 2: For functional groups, check if all endpoints are enabled
// Rule 3: For tool groups, they're enabled unless explicitly disabled (handled above)
if (isToolGroup(group)) {
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
return true;
}
// For functional groups, check each endpoint individually
for (String endpoint : endpoints) {
if (!isEndpointEnabledDirectly(endpoint)) {
@ -592,6 +592,7 @@ public class EndpointConfiguration {
|| "Pdftohtml".equals(group)
|| "ImageMagick".equals(group)
|| "rar".equals(group)
|| "Calibre".equals(group)
|| "FFmpeg".equals(group)
|| "veraPDF".equals(group);
}

View File

@ -1279,6 +1279,18 @@ optimizeForEbook = "Optimize PDF for ebook readers (uses Ghostscript)"
cbzOutputOptions = "PDF to CBZ Options"
cbzDpi = "DPI for image rendering"
[convert.ebookOptions]
ebookOptions = "eBook to PDF Options"
ebookOptionsDesc = "Options for converting eBooks to PDF"
embedAllFonts = "Embed all fonts"
embedAllFontsDesc = "Embed all fonts from the eBook into the generated PDF"
includeTableOfContents = "Include table of contents"
includeTableOfContentsDesc = "Add a generated table of contents to the resulting PDF"
includePageNumbers = "Include page numbers"
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)"
[imageToPdf]
tags = "conversion,img,jpg,picture,photo"

View File

@ -0,0 +1,96 @@
import { Stack, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
interface ConvertFromEbookSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}
const ConvertFromEbookSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromEbookSettingsProps) => {
const { t } = useTranslation();
const handleEmbedAllFontsChange = (value: boolean) => {
onParameterChange('ebookOptions', {
embedAllFonts: value,
includeTableOfContents: parameters.ebookOptions?.includeTableOfContents ?? false,
includePageNumbers: parameters.ebookOptions?.includePageNumbers ?? false,
optimizeForEbook: parameters.ebookOptions?.optimizeForEbook ?? false,
});
};
const handleIncludeTableOfContentsChange = (value: boolean) => {
onParameterChange('ebookOptions', {
embedAllFonts: parameters.ebookOptions?.embedAllFonts ?? false,
includeTableOfContents: value,
includePageNumbers: parameters.ebookOptions?.includePageNumbers ?? false,
optimizeForEbook: parameters.ebookOptions?.optimizeForEbook ?? false,
});
};
const handleIncludePageNumbersChange = (value: boolean) => {
onParameterChange('ebookOptions', {
embedAllFonts: parameters.ebookOptions?.embedAllFonts ?? false,
includeTableOfContents: parameters.ebookOptions?.includeTableOfContents ?? false,
includePageNumbers: value,
optimizeForEbook: parameters.ebookOptions?.optimizeForEbook ?? false,
});
};
const handleOptimizeForEbookChange = (value: boolean) => {
onParameterChange('ebookOptions', {
embedAllFonts: parameters.ebookOptions?.embedAllFonts ?? false,
includeTableOfContents: parameters.ebookOptions?.includeTableOfContents ?? false,
includePageNumbers: parameters.ebookOptions?.includePageNumbers ?? false,
optimizeForEbook: value,
});
};
// Initialize ebookOptions if not present
const ebookOptions = parameters.ebookOptions || {
embedAllFonts: false,
includeTableOfContents: false,
includePageNumbers: false,
optimizeForEbook: false,
};
return (
<Stack gap="sm">
<Checkbox
label={t("convert.ebookOptions.embedAllFonts", "Embed all fonts")}
description={t("convert.ebookOptions.embedAllFontsDesc", "Embed all fonts from the eBook into the generated PDF")}
checked={ebookOptions.embedAllFonts}
onChange={(event) => handleEmbedAllFontsChange(event.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t("convert.ebookOptions.includeTableOfContents", "Include table of contents")}
description={t("convert.ebookOptions.includeTableOfContentsDesc", "Add a generated table of contents to the resulting PDF")}
checked={ebookOptions.includeTableOfContents}
onChange={(event) => handleIncludeTableOfContentsChange(event.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t("convert.ebookOptions.includePageNumbers", "Include page numbers")}
description={t("convert.ebookOptions.includePageNumbersDesc", "Add page numbers to the generated PDF")}
checked={ebookOptions.includePageNumbers}
onChange={(event) => handleIncludePageNumbersChange(event.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t("convert.ebookOptions.optimizeForEbookPdf", "Optimize for ebook readers")}
description={t("convert.ebookOptions.optimizeForEbookPdfDesc", "Optimize the PDF for eBook reading (smaller file size, better rendering on eInk devices)")}
checked={ebookOptions.optimizeForEbook}
onChange={(event) => handleOptimizeForEbookChange(event.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
};
export default ConvertFromEbookSettings;

View File

@ -17,6 +17,7 @@ import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromE
import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings";
import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings";
import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings";
import ConvertFromEbookSettings from "@app/components/tools/convert/ConvertFromEbookSettings";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import {
FROM_FORMAT_OPTIONS,
@ -148,6 +149,12 @@ const ConvertSettings = ({
onParameterChange('cbzOutputOptions', {
dpi: 150,
});
onParameterChange('ebookOptions', {
embedAllFonts: false,
includeTableOfContents: false,
includePageNumbers: false,
optimizeForEbook: false,
});
onParameterChange('isSmartDetection', false);
onParameterChange('smartDetectionType', 'none');
};
@ -366,6 +373,18 @@ const ConvertSettings = ({
</>
)}
{/* eBook to PDF options */}
{['epub', 'mobi', 'azw3', 'fb2'].includes(parameters.fromExtension) && parameters.toExtension === 'pdf' && (
<>
<Divider />
<ConvertFromEbookSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
</Stack>
);
};

View File

@ -34,6 +34,7 @@ export const CONVERSION_ENDPOINTS = {
'html-pdf': '/api/v1/convert/html/pdf',
'markdown-pdf': '/api/v1/convert/markdown/pdf',
'eml-pdf': '/api/v1/convert/eml/pdf',
'ebook-pdf': '/api/v1/convert/ebook/pdf',
'pdf-text-editor': '/api/v1/convert/pdf/text-editor',
'text-editor-pdf': '/api/v1/convert/text-editor/pdf'
} as const;
@ -55,6 +56,7 @@ export const ENDPOINT_NAMES = {
'html-pdf': 'html-to-pdf',
'markdown-pdf': 'markdown-to-pdf',
'eml-pdf': 'eml-to-pdf',
'ebook-pdf': 'ebook-to-pdf',
'pdf-text-editor': 'pdf-to-text-editor',
'text-editor-pdf': 'text-editor-to-pdf'
} as const;
@ -89,6 +91,10 @@ export const FROM_FORMAT_OPTIONS = [
{ value: 'txt', label: 'TXT', group: 'Text' },
{ value: 'rtf', label: 'RTF', group: 'Text' },
{ value: 'eml', label: 'EML', group: 'Email' },
{ value: 'epub', label: 'EPUB', group: 'eBook' },
{ value: 'mobi', label: 'MOBI', group: 'eBook' },
{ value: 'azw3', label: 'AZW3', group: 'eBook' },
{ value: 'fb2', label: 'FB2', group: 'eBook' },
];
export const TO_FORMAT_OPTIONS = [
@ -127,7 +133,8 @@ export const CONVERSION_MATRIX: Record<string, string[]> = {
'zip': ['pdf'],
'md': ['pdf'],
'txt': ['pdf'], 'rtf': ['pdf'],
'eml': ['pdf']
'eml': ['pdf'],
'epub': ['pdf'], 'mobi': ['pdf'], 'azw3': ['pdf'], 'fb2': ['pdf']
};
// Map extensions to endpoint keys
@ -154,7 +161,8 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
'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' }
'eml': { 'pdf': 'eml-to-pdf' },
'epub': { 'pdf': 'ebook-to-pdf' }, 'mobi': { 'pdf': 'ebook-to-pdf' }, 'azw3': { 'pdf': 'ebook-to-pdf' }, 'fb2': { 'pdf': 'ebook-to-pdf' }
};
export type ColorType = typeof COLOR_TYPES[keyof typeof COLOR_TYPES];

View File

@ -28,6 +28,8 @@ export const shouldProcessFilesSeparately = (
// Web files to PDF conversions (each web file should generate its own PDF)
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
parameters.toExtension === 'pdf') ||
// eBook files to PDF conversions (each file should be processed separately via Calibre)
(['epub', 'mobi', 'azw3', 'fb2'].includes(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
// Web files smart detection
(parameters.isSmartDetection && parameters.smartDetectionType === 'web') ||
// Mixed file types (smart detection)
@ -43,7 +45,7 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
formData.append("fileInput", file);
});
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions, cbzOptions, cbzOutputOptions } = parameters;
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions, cbzOptions, cbzOutputOptions, ebookOptions } = parameters;
if (isImageFormat(toExtension)) {
formData.append("imageFormat", toExtension);
@ -75,6 +77,11 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
formData.append("optimizeForEbook", (cbzOptions?.optimizeForEbook ?? false).toString());
} else if (fromExtension === 'pdf' && toExtension === 'cbz') {
formData.append("dpi", (cbzOutputOptions?.dpi ?? 150).toString());
} else if (['epub', 'mobi', 'azw3', 'fb2'].includes(fromExtension) && toExtension === 'pdf') {
formData.append("embedAllFonts", (ebookOptions?.embedAllFonts ?? false).toString());
formData.append("includeTableOfContents", (ebookOptions?.includeTableOfContents ?? false).toString());
formData.append("includePageNumbers", (ebookOptions?.includePageNumbers ?? false).toString());
formData.append("optimizeForEbook", (ebookOptions?.optimizeForEbook ?? false).toString());
}
return formData;

View File

@ -42,6 +42,12 @@ export interface ConvertParameters extends BaseParameters {
cbzOutputOptions: {
dpi: number;
};
ebookOptions?: {
embedAllFonts: boolean;
includeTableOfContents: boolean;
includePageNumbers: boolean;
optimizeForEbook: boolean;
};
isSmartDetection: boolean;
smartDetectionType: 'mixed' | 'images' | 'web' | 'none';
}
@ -81,6 +87,12 @@ export const defaultParameters: ConvertParameters = {
cbzOutputOptions: {
dpi: 150,
},
ebookOptions: {
embedAllFonts: false,
includeTableOfContents: false,
includePageNumbers: false,
optimizeForEbook: false,
},
isSmartDetection: false,
smartDetectionType: 'none',
};