[V2] feat(convert): add support for CBR to PDF and PDF to CBR conversions (#4833)

# Description of Changes
TLDR:
- Introduced `ConvertFromCbrSettings` and `ConvertToCbrSettings`
components for handling conversion-specific settings.
- Updated `ConvertSettings` to include CBR-related configurations.
- Updated `useConvertParameters` with new parameters: `cbrOptions` and
`pdfToCbrOptions`.
- Updated locales with CBR-related translations.
- Added endpoints and updated conversion matrix for CBR formats in
`convertConstants`.
- Modified `useConvertOperation` to handle CBR-specific parameters
during form data preparation.


For backend see this PR: #4581

This pull request adds support for converting between CBR (Comic Book
Archive) and PDF formats in the frontend. It introduces new UI
components for configuring conversion options, updates the conversion
constants and logic, and ensures parameters for these conversions are
handled correctly throughout the app.

**CBR/PDF Conversion Features:**

* Added new UI components: `ConvertFromCbrSettings` for CBR to PDF
conversions (option to optimize for ebook readers), and
`ConvertToCbrSettings` for PDF to CBR conversions (DPI selection for
image rendering). These are conditionally rendered in
`ConvertSettings.tsx` based on selected formats.

**Conversion Logic and Constants:**

* Extended conversion endpoints, format options, conversion matrix, and
endpoint mappings to support CBR to PDF and PDF to CBR conversions.
* Updated `shouldProcessFilesSeparately` logic to handle PDF to CBR
conversions, ensuring each PDF generates its own archive.

**Parameter Handling:**

* Added new parameters (`cbrOptions` and `pdfToCbrOptions`) to the
conversion parameter interface and default values, ensuring they are
set/reset appropriately during format changes
* Modified form data construction to include new options for CBR/PDF
conversions (optimize for ebook, DPI).

### Frontend
<img width="1291" height="861" alt="image"
src="https://github.com/user-attachments/assets/fb63be66-6f40-4dde-8235-86c9ddfa1f7c"
/>


<img width="1411" height="1000" alt="image"
src="https://github.com/user-attachments/assets/529593c4-6f32-4b11-9754-7f334f40d32e"
/>


Note on RAR5. You can go back-and-forth with this converter due to the
fact on default pdf to cbr makes RAR5 meanwhile, cbr to pdf can only
RAR4 and below. This is unfortunate limitation of JunRAR library. In the
real world, generally RAR5s are not that common, and they mostly
unsupported by open-source software.

<!--
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>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
This commit is contained in:
Balázs Szücs 2025-12-27 02:32:55 +01:00 committed by GitHub
parent 98fa5dfcc1
commit 182eb504de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 229 additions and 6 deletions

View File

@ -1278,6 +1278,9 @@ cbzOptions = "CBZ to PDF Options"
optimizeForEbook = "Optimize PDF for ebook readers (uses Ghostscript)"
cbzOutputOptions = "PDF to CBZ Options"
cbzDpi = "DPI for image rendering"
cbrOptions = "CBR Options"
cbrOutputOptions = "PDF to CBR Options"
cbrDpi = "DPI for image rendering"
[convert.ebookOptions]
ebookOptions = "eBook to PDF Options"

View File

@ -112,6 +112,7 @@ const FileEditorThumbnail = ({
}, [file.name]);
const isCBZ = extLower === 'cbz';
const isCBR = extLower === 'cbr';
const pageLabel = useMemo(
() =>
@ -226,7 +227,7 @@ const FileEditorThumbnail = ({
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
}
},
hidden: !isZipFile || !onUnzipFile || isCBZ,
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
},
{
id: 'close',
@ -238,7 +239,7 @@ const FileEditorThumbnail = ({
},
color: 'red',
}
], [t, file.id, file.name, isZipFile, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]);
], [t, file.id, file.name, isZipFile, isCBR, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]);
// ---- Card interactions ----
const handleCardClick = () => {

View File

@ -52,6 +52,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
// Check file extension
const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || '').toLowerCase();
const isCBZ = extLower === 'cbz';
const isCBR = extLower === 'cbr';
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
@ -240,7 +245,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
)}
{/* Unzip option for ZIP files */}
{isZipFile && !isHistoryFile && (
{isZipFile && !isHistoryFile && !isCBZ && !isCBR && (
<>
<Menu.Item
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}

View File

@ -0,0 +1,36 @@
import { Stack, Text, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
interface ConvertFromCbrSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}
const ConvertFromCbrSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromCbrSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="cbr-settings">
<Text size="sm" fw={500}>{t("convert.cbrOptions", "CBR Options")}:</Text>
<Checkbox
label={t('convert.optimizeForEbook', 'Optimize PDF for ebook readers (uses Ghostscript)')}
checked={parameters.cbrOptions.optimizeForEbook}
onChange={(event) => onParameterChange('cbrOptions', {
...parameters.cbrOptions,
optimizeForEbook: event.currentTarget.checked
})}
disabled={disabled}
data-testid="optimize-ebook-checkbox"
/>
</Stack>
);
};
export default ConvertFromCbrSettings;

View File

@ -17,6 +17,8 @@ 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 ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings";
import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings";
import ConvertFromEbookSettings from "@app/components/tools/convert/ConvertFromEbookSettings";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import {
@ -143,6 +145,12 @@ const ConvertSettings = ({
onParameterChange('pdfaOptions', {
outputFormat: 'pdfa-1',
});
onParameterChange('cbrOptions', {
optimizeForEbook: false,
});
onParameterChange('pdfToCbrOptions', {
dpi: 150,
});
onParameterChange('cbzOptions', {
optimizeForEbook: false,
});
@ -217,6 +225,12 @@ const ConvertSettings = ({
onParameterChange('pdfaOptions', {
outputFormat: 'pdfa-1',
});
onParameterChange('cbrOptions', {
optimizeForEbook: false,
});
onParameterChange('pdfToCbrOptions', {
dpi: 150,
});
onParameterChange('cbzOptions', {
optimizeForEbook: false,
});
@ -385,6 +399,30 @@ const ConvertSettings = ({
</>
)}
{/* CBR to PDF options */}
{parameters.fromExtension === 'cbr' && parameters.toExtension === 'pdf' && (
<>
<Divider />
<ConvertFromCbrSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* PDF to CBR options */}
{parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbr' && (
<>
<Divider />
<ConvertToCbrSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
</Stack>
);
};

View File

@ -0,0 +1,39 @@
import { Stack, Text, NumberInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
interface ConvertToCbrSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}
const ConvertToCbrSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertToCbrSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="cbr-output-settings">
<Text size="sm" fw={500}>{t("convert.cbrOutputOptions", "PDF to CBR Options")}:</Text>
<NumberInput
data-testid="cbr-dpi-input"
label={t("convert.cbrDpi", "DPI for image rendering")}
value={parameters.pdfToCbrOptions.dpi}
onChange={(val) =>
typeof val === 'number' &&
onParameterChange('pdfToCbrOptions', { ...parameters.pdfToCbrOptions, dpi: val })
}
min={72}
max={600}
step={50}
disabled={disabled}
/>
</Stack>
);
};
export default ConvertToCbrSettings;

View File

@ -34,6 +34,8 @@ 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',
'cbr-pdf': '/api/v1/convert/cbr/pdf',
'pdf-cbr': '/api/v1/convert/pdf/cbr',
'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'
@ -57,6 +59,8 @@ export const ENDPOINT_NAMES = {
'markdown-pdf': 'markdown-to-pdf',
'eml-pdf': 'eml-to-pdf',
'ebook-pdf': 'ebook-to-pdf',
'cbr-pdf': 'cbr-to-pdf',
'pdf-cbr': 'pdf-to-cbr',
'pdf-text-editor': 'pdf-to-text-editor',
'text-editor-pdf': 'text-editor-to-pdf'
} as const;
@ -68,6 +72,7 @@ export const FROM_FORMAT_OPTIONS = [
{ value: 'image', label: 'Images', group: 'Multiple Files' },
{ value: 'pdf', label: 'PDF', group: 'Document' },
{ value: 'cbz', label: 'CBZ', group: 'Archive' },
{ value: 'cbr', label: 'CBR', group: 'Archive' },
{ value: 'docx', label: 'DOCX', group: 'Document' },
{ value: 'doc', label: 'DOC', group: 'Document' },
{ value: 'odt', label: 'ODT', group: 'Document' },
@ -103,6 +108,7 @@ export const TO_FORMAT_OPTIONS = [
{ value: 'docx', label: 'DOCX', group: 'Document' },
{ value: 'odt', label: 'ODT', group: 'Document' },
{ value: 'cbz', label: 'CBZ', group: 'Archive' },
{ value: 'cbr', label: 'CBR', group: 'Archive' },
{ value: 'csv', label: 'CSV', group: 'Spreadsheet' },
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
{ value: 'odp', label: 'ODP', group: 'Presentation' },
@ -123,7 +129,7 @@ export const TO_FORMAT_OPTIONS = [
export const CONVERSION_MATRIX: Record<string, string[]> = {
'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'],
'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa', 'cbz', 'cbr'],
'cbz': ['pdf'],
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'],
@ -134,6 +140,7 @@ export const CONVERSION_MATRIX: Record<string, string[]> = {
'md': ['pdf'],
'txt': ['pdf'], 'rtf': ['pdf'],
'eml': ['pdf'],
'cbr': ['pdf'],
'epub': ['pdf'], 'mobi': ['pdf'], 'azw3': ['pdf'], 'fb2': ['pdf']
};
@ -149,6 +156,7 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown',
'html': 'pdf-to-html', 'xml': 'pdf-to-xml',
'pdfa': 'pdf-to-pdfa',
'cbr': 'pdf-to-cbr',
'cbz': 'pdf-to-cbz'
},
'cbz': { 'pdf': 'cbz-to-pdf' },
@ -161,6 +169,7 @@ 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' },
'cbr': { 'pdf': 'cbr-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' }
};

View File

@ -15,7 +15,7 @@ export const CONVERT_SUPPORTED_FORMATS = [
// Ebook formats
'epub', 'mobi', 'azw3', 'fb2',
// Archive formats
'zip', 'cbz',
'zip', 'cbr', 'cbz',
// Other
'dbf', 'fods', 'vsd', 'vor', 'vor3', 'vor4', 'uop', 'pct', 'ps', 'pdf',
];

View File

@ -21,6 +21,8 @@ export const shouldProcessFilesSeparately = (
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
// PDF to text-like formats should be one output per input
(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 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)
@ -40,12 +42,12 @@ 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;
selectedFiles.forEach(file => {
formData.append("fileInput", file);
});
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions, cbzOptions, cbzOutputOptions, ebookOptions } = parameters;
if (isImageFormat(toExtension)) {
formData.append("imageFormat", toExtension);
@ -73,6 +75,10 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
formData.append("outputFormat", pdfaOptions.outputFormat);
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
formData.append("pageNumbers", "all");
} else if (fromExtension === 'cbr' && toExtension === 'pdf') {
formData.append("optimizeForEbook", cbrOptions.optimizeForEbook.toString());
} else if (fromExtension === 'pdf' && toExtension === 'cbr') {
formData.append("dpi", pdfToCbrOptions.dpi.toString());
} else if (fromExtension === 'cbz' && toExtension === 'pdf') {
formData.append("optimizeForEbook", (cbzOptions?.optimizeForEbook ?? false).toString());
} else if (fromExtension === 'pdf' && toExtension === 'cbz') {

View File

@ -36,6 +36,12 @@ export interface ConvertParameters extends BaseParameters {
pdfaOptions: {
outputFormat: string;
};
cbrOptions: {
optimizeForEbook: boolean;
};
pdfToCbrOptions: {
dpi: number;
};
cbzOptions: {
optimizeForEbook: boolean;
};
@ -81,6 +87,12 @@ export const defaultParameters: ConvertParameters = {
pdfaOptions: {
outputFormat: 'pdfa-1',
},
cbrOptions: {
optimizeForEbook: false,
},
pdfToCbrOptions: {
dpi: 150,
},
cbzOptions: {
optimizeForEbook: false,
},

View File

@ -152,6 +152,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -225,6 +231,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -276,6 +288,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -336,6 +354,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -400,6 +424,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -462,6 +492,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -520,6 +556,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -575,6 +617,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -632,6 +680,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -686,6 +740,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -746,6 +806,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},
@ -805,6 +871,12 @@ describe('Convert Tool Integration Tests', () => {
pdfaOptions: {
outputFormat: ''
},
cbrOptions: {
optimizeForEbook: false
},
pdfToCbrOptions: {
dpi: 150
},
cbzOptions: {
optimizeForEbook: false
},

View File

@ -28,6 +28,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/pdf-to-pdfa': 'convert',
'/pdf-to-word': 'convert',
'/pdf-to-xml': 'convert',
'/cbr-to-pdf': 'convert',
'/pdf-to-cbr': 'convert',
'/cbz-to-pdf': 'convert',
'/pdf-to-cbz': 'convert',