feat(conversion): add eBook to PDF via Calibre (EPUB/MOBI/AZW3/FB2/TXT/DOCX) (#4644)

This pull request adds support for converting common eBook formats
(EPUB, MOBI, AZW3, FB2, TXT, DOCX) to PDF using Calibre. It introduces a
new API endpoint and updates the configuration, dependency checks, and
documentation to support this feature. Additionally, it includes related
UI and localization changes.

**New eBook to PDF conversion feature:**

* Added `ConvertEbookToPDFController` with a new
`/api/v1/convert/ebook/pdf` endpoint to handle eBook to PDF conversion
using Calibre, supporting options like embedding fonts, including table
of contents, and page numbers.
* Introduced `ConvertEbookToPdfRequest` model for handling conversion
requests and options.

**Configuration and dependency management:**

* Updated `RuntimePathConfig`, `ApplicationProperties`, and
`ExternalAppDepConfig` to support Calibre's executable path
configuration and dependency checking, ensuring Calibre is available and
correctly integrated.
[[1]](diffhunk://#diff-68c561052c2376c3d494bf11dd821958acd9917b1b2d33a7195ca2d6df7ec517R24)
[[2]](diffhunk://#diff-68c561052c2376c3d494bf11dd821958acd9917b1b2d33a7195ca2d6df7ec517R61)
[[3]](diffhunk://#diff-68c561052c2376c3d494bf11dd821958acd9917b1b2d33a7195ca2d6df7ec517R72-R74)
[[4]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R359)
[[5]](diffhunk://#diff-8932df49d210349a062949da2ed43ce769b0f107354880a78103664f008f849eR26-R34)
[[6]](diffhunk://#diff-8932df49d210349a062949da2ed43ce769b0f107354880a78103664f008f849eR48)
[[7]](diffhunk://#diff-8932df49d210349a062949da2ed43ce769b0f107354880a78103664f008f849eR63-R68)
[[8]](diffhunk://#diff-8932df49d210349a062949da2ed43ce769b0f107354880a78103664f008f849eR132)
* Registered the new endpoint and tool group in `EndpointConfiguration`,
including logic to enable/disable the feature based on Calibre's
presence.
[[1]](diffhunk://#diff-3cddb66d1cf93eeb8103ccd17cee8ed006e0c0ee006d0ee1cf42d512f177e437R260)
[[2]](diffhunk://#diff-3cddb66d1cf93eeb8103ccd17cee8ed006e0c0ee006d0ee1cf42d512f177e437R440-R442)
[[3]](diffhunk://#diff-3cddb66d1cf93eeb8103ccd17cee8ed006e0c0ee006d0ee1cf42d512f177e437L487-R492)

**Documentation and localization:**

* Updated the `README.md` to mention eBook to PDF conversion support.
* Added UI route and form for eBook to PDF conversion in the web
controller.
* Added English and German localization strings for the new feature,
including descriptions, labels, and error messages.
[[1]](diffhunk://#diff-ee1c6999a33498cfa3abba4a384e73a8b8269856899438de80560c965079a9fdR617-R620)
[[2]](diffhunk://#diff-482633b22866efc985222c4a14efc5b7d2487b59f39b953f038273a39d0362f7R617-R620)
[[3]](diffhunk://#diff-482633b22866efc985222c4a14efc5b7d2487b59f39b953f038273a39d0362f7R1476-R1485)


## 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)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] 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.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
Ludy
2025-11-11 22:44:18 +01:00
committed by GitHub
parent e932ca01f3
commit 19aef5e034
22 changed files with 749 additions and 30 deletions

View File

@@ -257,6 +257,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "html-to-pdf");
addEndpointToGroup("Convert", "url-to-pdf");
addEndpointToGroup("Convert", "markdown-to-pdf");
addEndpointToGroup("Convert", "ebook-to-pdf");
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf");
@@ -446,6 +447,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
addEndpointToGroup("Weasyprint", "eml-to-pdf");
// Calibre dependent endpoints
addEndpointToGroup("Calibre", "ebook-to-pdf");
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
@@ -498,6 +502,7 @@ public class EndpointConfiguration {
|| "Javascript".equals(group)
|| "Weasyprint".equals(group)
|| "Pdftohtml".equals(group)
|| "Calibre".equals(group)
|| "rar".equals(group)
|| "FFmpeg".equals(group);
}

View File

@@ -40,6 +40,7 @@ public class ExternalAppDepConfig {
private final String weasyprintPath;
private final String unoconvPath;
private final String calibrePath;
/**
* Map of command(binary) -> affected groups (e.g. "gs" -> ["Ghostscript"]). Immutable to avoid
@@ -56,6 +57,7 @@ public class ExternalAppDepConfig {
this.endpointConfiguration = endpointConfiguration;
this.weasyprintPath = runtimePathConfig.getWeasyPrintPath();
this.unoconvPath = runtimePathConfig.getUnoConvertPath();
this.calibrePath = runtimePathConfig.getCalibrePath();
Map<String, List<String>> tmp = new HashMap<>();
tmp.put("gs", List.of("Ghostscript"));
@@ -67,6 +69,7 @@ public class ExternalAppDepConfig {
tmp.put("qpdf", List.of("qpdf"));
tmp.put("tesseract", List.of("tesseract"));
tmp.put("rar", List.of("rar"));
tmp.put(calibrePath, List.of("Calibre"));
tmp.put("ffmpeg", List.of("FFmpeg"));
this.commandToGroupMapping = Collections.unmodifiableMap(tmp);
}

View File

@@ -0,0 +1,208 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
@Slf4j
public class ConvertEbookToPDFController {
private static final Set<String> SUPPORTED_EXTENSIONS =
Set.of("epub", "mobi", "azw3", "fb2", "txt", "docx");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
private boolean isCalibreEnabled() {
return endpointConfiguration.isGroupEnabled("Calibre");
}
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
@Operation(
summary = "Convert an eBook file to PDF",
description =
"This endpoint converts common eBook formats (EPUB, MOBI, AZW3, FB2, TXT, DOCX)"
+ " to PDF using Calibre. Input:BOOK Output:PDF Type:SISO")
public ResponseEntity<byte[]> convertEbookToPdf(
@ModelAttribute ConvertEbookToPdfRequest request) throws Exception {
if (!isCalibreEnabled()) {
throw new IllegalStateException("Calibre support is disabled");
}
MultipartFile inputFile = request.getFileInput();
if (inputFile == null || inputFile.isEmpty()) {
throw new IllegalArgumentException("No input file provided");
}
boolean optimizeForEbook = Boolean.TRUE.equals(request.getOptimizeForEbook());
if (optimizeForEbook && !isGhostscriptEnabled()) {
log.warn(
"Ghostscript optimization requested but Ghostscript is not enabled/available"
+ " for ebook conversion");
optimizeForEbook = false;
}
boolean embedAllFonts = Boolean.TRUE.equals(request.getEmbedAllFonts());
boolean includeTableOfContents = Boolean.TRUE.equals(request.getIncludeTableOfContents());
boolean includePageNumbers = Boolean.TRUE.equals(request.getIncludePageNumbers());
String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "document";
}
String extension = FilenameUtils.getExtension(originalFilename);
if (extension == null || extension.isBlank()) {
throw new IllegalArgumentException("Unable to determine file type");
}
String lowerExtension = extension.toLowerCase(Locale.ROOT);
if (!SUPPORTED_EXTENSIONS.contains(lowerExtension)) {
throw new IllegalArgumentException("Unsupported eBook file extension: " + extension);
}
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
baseName = "document";
}
Path workingDirectory = tempFileManager.createTempDirectory();
Path inputPath = workingDirectory.resolve(baseName + "." + lowerExtension);
Path outputPath = workingDirectory.resolve(baseName + ".pdf");
try (InputStream inputStream = inputFile.getInputStream()) {
Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING);
}
List<String> command =
buildCalibreCommand(
inputPath,
outputPath,
embedAllFonts,
includeTableOfContents,
includePageNumbers);
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.CALIBRE)
.runCommandWithOutputHandling(command, workingDirectory.toFile());
if (result == null) {
throw new IllegalStateException("Calibre conversion returned no result");
}
if (result.getRc() != 0) {
String errorMessage = result.getMessages();
if (errorMessage == null || errorMessage.isBlank()) {
errorMessage = "Calibre conversion failed";
}
throw new IllegalStateException(errorMessage);
}
if (!Files.exists(outputPath) || Files.size(outputPath) == 0L) {
throw new IllegalStateException("Calibre did not produce a PDF output");
}
String outputFilename =
GeneralUtils.generateFilename(originalFilename, "_convertedToPDF.pdf");
try {
if (optimizeForEbook) {
byte[] pdfBytes = Files.readAllBytes(outputPath);
try {
byte[] optimizedPdf = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
return WebResponseUtils.bytesToWebResponse(optimizedPdf, outputFilename);
} catch (IOException e) {
log.warn(
"Ghostscript optimization failed for ebook conversion, returning"
+ " original PDF",
e);
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
}
try (PDDocument document = pdfDocumentFactory.load(outputPath.toFile())) {
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
}
} finally {
cleanupTempFiles(workingDirectory, inputPath, outputPath);
}
}
private List<String> buildCalibreCommand(
Path inputPath,
Path outputPath,
boolean embedAllFonts,
boolean includeTableOfContents,
boolean includePageNumbers) {
List<String> command = new ArrayList<>();
command.add("ebook-convert");
command.add(inputPath.toString());
command.add(outputPath.toString());
if (embedAllFonts) {
command.add("--embed-all-fonts");
}
if (includeTableOfContents) {
command.add("--pdf-add-toc");
}
if (includePageNumbers) {
command.add("--pdf-page-numbers");
}
return command;
}
private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) {
List<Path> pathsToDelete = new ArrayList<>();
pathsToDelete.add(inputPath);
pathsToDelete.add(outputPath);
for (Path path : pathsToDelete) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete temporary file: {}", path, e);
}
}
tempFileManager.deleteTempDirectory(workingDirectory);
}
}

View File

@@ -47,6 +47,13 @@ public class ConverterWebController {
return "convert/cbr-to-pdf";
}
@GetMapping("/ebook-to-pdf")
@Hidden
public String convertEbookToPdfForm(Model model) {
model.addAttribute("currentPage", "ebook-to-pdf");
return "convert/ebook-to-pdf";
}
@GetMapping("/pdf-to-cbr")
@Hidden
public String convertPdfToCbrForm(Model model) {

View File

@@ -0,0 +1,53 @@
package stirling.software.SPDF.model.api.converters;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class ConvertEbookToPdfRequest {
@Schema(
description =
"The input eBook file to be converted to a PDF file (EPUB, MOBI, AZW3, FB2,"
+ " TXT, DOCX)",
contentMediaType =
"application/epub+zip, application/x-mobipocket-ebook, application/x-azw3,"
+ " text/xml, text/plain,"
+ " application/vnd.openxmlformats-officedocument.wordprocessingml.document",
requiredMode = Schema.RequiredMode.REQUIRED)
private MultipartFile fileInput;
@Schema(
description = "Embed all fonts from the eBook into the generated PDF",
allowableValues = {"true", "false"},
requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false")
private Boolean embedAllFonts;
@Schema(
description = "Add a generated table of contents to the resulting PDF",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean includeTableOfContents;
@Schema(
description = "Add page numbers to the generated PDF",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean includePageNumbers;
@Schema(
description =
"Optimize the PDF for eBook reading (smaller file size, better rendering on"
+ " eInk devices)",
allowableValues = {"true", "false"},
defaultValue = "false")
private Boolean optimizeForEbook;
}

View File

@@ -616,6 +616,10 @@ home.cbrToPdf.title=CBR zu PDF
home.cbrToPdf.desc=CBR-Comicarchive in das PDF-Format konvertieren.
cbrToPdf.tags=konvertierung,comic,buch,archiv,cbr,rar
home.ebookToPdf.title=E-Book zu PDF
home.ebookToPdf.desc=E-Book-Dateien (EPUB, MOBI, AZW3, FB2, TXT, DOCX) mit Calibre in PDF konvertieren.
ebookToPdf.tags=konvertierung,ebook,calibre,epub,mobi,azw3
home.pdfToCbz.title=PDF zu CBZ
home.pdfToCbz.desc=PDF-Dateien in CBZ-Comicarchive umwandeln.
pdfToCbz.tags=konvertierung,comic,buch,archiv,cbz,pdf
@@ -1490,6 +1494,17 @@ cbrToPDF.submit=Zu PDF konvertieren
cbrToPDF.selectText=CBR-Datei auswählen
cbrToPDF.optimizeForEbook=PDF für E-Book-Reader optimieren (verwendet Ghostscript)
#ebookToPDF
ebookToPDF.title=E-Book zu PDF
ebookToPDF.header=E-Book zu PDF
ebookToPDF.submit=Zu PDF konvertieren
ebookToPDF.selectText=E-Book-Datei auswählen
ebookToPDF.embedAllFonts=Alle Schriftarten in der erzeugten PDF einbetten (kann die Dateigröße erhöhen)
ebookToPDF.includeTableOfContents=Inhaltsverzeichnis zur erzeugten PDF hinzufügen
ebookToPDF.includePageNumbers=Seitenzahlen zur erzeugten PDF hinzufügen
ebookToPDF.optimizeForEbook=PDF für E-Book-Reader optimieren (verwendet Ghostscript)
ebookToPDF.calibreDisabled=Calibre-Unterstützung ist deaktiviert. Aktivieren Sie die Calibre-Werkzeuggruppe oder installieren Sie Calibre, um diese Funktion zu nutzen.
#pdfToCBR
pdfToCBR.title=PDF zu CBR
pdfToCBR.header=PDF zu CBR

View File

@@ -616,6 +616,10 @@ home.cbrToPdf.title=CBR to PDF
home.cbrToPdf.desc=Convert CBR comic book archives to PDF format.
cbrToPdf.tags=conversion,comic,book,archive,cbr,rar
home.ebookToPdf.title=eBook to PDF
home.ebookToPdf.desc=Convert eBook files (EPUB, MOBI, AZW3, FB2, TXT, DOCX) to PDF using Calibre.
ebookToPdf.tags=conversion,ebook,calibre,epub,mobi,azw3
home.pdfToCbz.title=PDF to CBZ
home.pdfToCbz.desc=Convert PDF files to CBZ comic book archives.
pdfToCbz.tags=conversion,comic,book,archive,cbz,pdf
@@ -1490,6 +1494,17 @@ cbrToPDF.submit=Convert to PDF
cbrToPDF.selectText=Select CBR file
cbrToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript)
#ebookToPDF
ebookToPDF.title=eBook to PDF
ebookToPDF.header=eBook to PDF
ebookToPDF.submit=Convert to PDF
ebookToPDF.selectText=Select eBook file
ebookToPDF.embedAllFonts=Embed all fonts in the output PDF (may increase file size)
ebookToPDF.includeTableOfContents=Add a generated table of contents to the PDF
ebookToPDF.includePageNumbers=Add page numbers to the generated PDF
ebookToPDF.optimizeForEbook=Optimize PDF for ebook readers (uses Ghostscript)
ebookToPDF.calibreDisabled=Calibre support is disabled. Enable the Calibre tool group or install Calibre to use this feature.
#pdfToCBR
pdfToCBR.title=PDF to CBR
pdfToCBR.header=PDF to CBR

View File

@@ -149,6 +149,7 @@ system:
operations:
weasyprint: '' # Defaults to /opt/venv/bin/weasyprint
unoconvert: '' # Defaults to /opt/venv/bin/unoconvert
calibre: '' # Defaults to /usr/bin/ebook-convert
fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
tempFileManagement:
baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf

View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html th:data-language="${#locale.toString()}"
th:dir="#{language.direction}"
th:lang="${#locale.language}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{ebookToPDF.title}, header=#{ebookToPDF.header})}"></th:block>
</head>
<body>
<th:block th:insert="~{fragments/common :: game}"></th:block>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon convertto">menu_book</span>
<span class="tool-header-text"
th:text="#{ebookToPDF.header}"></span>
</div>
<p th:text="#{processTimeWarning}"></p>
<div class="alert alert-warning"
th:if="${!@endpointConfiguration.isGroupEnabled('Calibre')}">
<span th:text="#{ebookToPDF.calibreDisabled}">Calibre support is disabled.</span>
</div>
<form enctype="multipart/form-data"
id="ebookToPDFForm"
method="post"
th:action="@{'/api/v1/convert/ebook/pdf'}"
th:if="${@endpointConfiguration.isGroupEnabled('Calibre')}">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.epub,.mobi,.azw3,.fb2,.txt,.docx', inputText=#{ebookToPDF.selectText})}">
</div>
<div class="form-check mb-2">
<input class="form-check-input"
id="embedAllFonts"
name="embedAllFonts"
type="checkbox"
value="true">
<label for="embedAllFonts"
th:text="#{ebookToPDF.embedAllFonts}">
Embed all fonts in PDF
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input"
id="includeTableOfContents"
name="includeTableOfContents"
type="checkbox"
value="true">
<label
for="includeTableOfContents"
th:text="#{ebookToPDF.includeTableOfContents}">
Add table of contents
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input"
id="includePageNumbers"
name="includePageNumbers"
type="checkbox"
value="true">
<label
for="includePageNumbers"
th:text="#{ebookToPDF.includePageNumbers}">
Add page numbers
</label>
</div>
<div class="form-check mb-3"
th:if="${@endpointConfiguration.isGroupEnabled('Ghostscript')}">
<input class="form-check-input"
id="optimizeForEbook"
name="optimizeForEbook"
type="checkbox"
value="true">
<label
for="optimizeForEbook"
th:text="#{ebookToPDF.optimizeForEbook}">
Optimize PDF for ebook readers (uses Ghostscript)
</label>
</div>
<button class="btn btn-primary"
id="submitBtn"
th:text="#{ebookToPDF.submit}"
type="submit">Convert to
PDF</button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>

View File

@@ -53,6 +53,9 @@
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('ebook-to-pdf', 'menu_book', 'home.ebookToPdf.title', 'home.ebookToPdf.desc', 'ebookToPdf.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}">
</div>
@@ -132,6 +135,9 @@
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('cbr-to-pdf', 'auto_stories', 'home.cbrToPdf.title', 'home.cbrToPdf.desc', 'cbrToPdf.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('ebook-to-pdf', 'menu_book', 'home.ebookToPdf.title', 'home.ebookToPdf.desc', 'ebookToPdf.tags', 'convertto')}">
</div>
<div
th:replace="~{fragments/navbarEntry :: navbarEntry('file-to-pdf', 'draft', 'home.fileToPDF.title', 'home.fileToPDF.desc', 'fileToPDF.tags', 'convertto')}">
</div>

View File

@@ -0,0 +1,266 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.ProcessExecutor.Processes;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ExtendWith(MockitoExtension.class)
class ConvertEbookToPDFControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private EndpointConfiguration endpointConfiguration;
@InjectMocks private ConvertEbookToPDFController controller;
@Test
void convertEbookToPdf_buildsCalibreCommandAndCleansUp() throws Exception {
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
MockMultipartFile ebookFile =
new MockMultipartFile(
"fileInput", "ebook.epub", "application/epub+zip", "content".getBytes());
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
request.setFileInput(ebookFile);
request.setEmbedAllFonts(true);
request.setIncludeTableOfContents(true);
request.setIncludePageNumbers(true);
Path workingDir = Files.createTempDirectory("ebook-convert-test-");
when(tempFileManager.createTempDirectory()).thenReturn(workingDir);
AtomicReference<Path> deletedDir = new AtomicReference<>();
Mockito.doAnswer(
invocation -> {
Path dir = invocation.getArgument(0);
deletedDir.set(dir);
if (Files.exists(dir)) {
try (Stream<Path> paths = Files.walk(dir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
return null;
})
.when(tempFileManager)
.deleteTempDirectory(any(Path.class));
PDDocument mockDocument = Mockito.mock(PDDocument.class);
when(pdfDocumentFactory.load(any(File.class))).thenReturn(mockDocument);
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor);
ProcessExecutorResult execResult = Mockito.mock(ProcessExecutorResult.class);
when(execResult.getRc()).thenReturn(0);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
Path expectedInput = workingDir.resolve("ebook.epub");
Path expectedOutput = workingDir.resolve("ebook.pdf");
when(executor.runCommandWithOutputHandling(
commandCaptor.capture(), eq(workingDir.toFile())))
.thenAnswer(
invocation -> {
Files.writeString(expectedOutput, "pdf");
return execResult;
});
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok("result".getBytes());
wr.when(
() ->
WebResponseUtils.pdfDocToWebResponse(
mockDocument, "ebook_convertedToPDF.pdf"))
.thenReturn(expectedResponse);
gu.when(() -> GeneralUtils.generateFilename("ebook.epub", "_convertedToPDF.pdf"))
.thenReturn("ebook_convertedToPDF.pdf");
ResponseEntity<byte[]> response = controller.convertEbookToPdf(request);
assertSame(expectedResponse, response);
List<String> command = commandCaptor.getValue();
assertEquals(6, command.size());
assertEquals("ebook-convert", command.get(0));
assertEquals(expectedInput.toString(), command.get(1));
assertEquals(expectedOutput.toString(), command.get(2));
assertEquals("--embed-all-fonts", command.get(3));
assertEquals("--pdf-add-toc", command.get(4));
assertEquals("--pdf-page-numbers", command.get(5));
assertFalse(Files.exists(expectedInput));
assertFalse(Files.exists(expectedOutput));
assertEquals(workingDir, deletedDir.get());
Mockito.verify(tempFileManager).deleteTempDirectory(workingDir);
}
if (Files.exists(workingDir)) {
try (Stream<Path> paths = Files.walk(workingDir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
}
@Test
void convertEbookToPdf_withUnsupportedExtensionThrows() {
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
MockMultipartFile unsupported =
new MockMultipartFile(
"fileInput", "ebook.exe", "application/octet-stream", new byte[] {1, 2, 3});
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
request.setFileInput(unsupported);
assertThrows(IllegalArgumentException.class, () -> controller.convertEbookToPdf(request));
}
@Test
void convertEbookToPdf_withOptimizeForEbookUsesGhostscript() throws Exception {
when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true);
when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true);
MockMultipartFile ebookFile =
new MockMultipartFile(
"fileInput", "ebook.epub", "application/epub+zip", "content".getBytes());
ConvertEbookToPdfRequest request = new ConvertEbookToPdfRequest();
request.setFileInput(ebookFile);
request.setOptimizeForEbook(true);
Path workingDir = Files.createTempDirectory("ebook-convert-opt-test-");
when(tempFileManager.createTempDirectory()).thenReturn(workingDir);
AtomicReference<Path> deletedDir = new AtomicReference<>();
Mockito.doAnswer(
invocation -> {
Path dir = invocation.getArgument(0);
deletedDir.set(dir);
if (Files.exists(dir)) {
try (Stream<Path> paths = Files.walk(dir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
return null;
})
.when(tempFileManager)
.deleteTempDirectory(any(Path.class));
try (MockedStatic<ProcessExecutor> pe = Mockito.mockStatic(ProcessExecutor.class);
MockedStatic<GeneralUtils> gu = Mockito.mockStatic(GeneralUtils.class);
MockedStatic<WebResponseUtils> wr = Mockito.mockStatic(WebResponseUtils.class)) {
ProcessExecutor executor = Mockito.mock(ProcessExecutor.class);
pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor);
ProcessExecutorResult execResult = Mockito.mock(ProcessExecutorResult.class);
when(execResult.getRc()).thenReturn(0);
Path expectedInput = workingDir.resolve("ebook.epub");
Path expectedOutput = workingDir.resolve("ebook.pdf");
when(executor.runCommandWithOutputHandling(any(List.class), eq(workingDir.toFile())))
.thenAnswer(
invocation -> {
Files.writeString(expectedOutput, "pdf");
return execResult;
});
gu.when(() -> GeneralUtils.generateFilename("ebook.epub", "_convertedToPDF.pdf"))
.thenReturn("ebook_convertedToPDF.pdf");
byte[] optimizedBytes = "optimized".getBytes(StandardCharsets.UTF_8);
gu.when(() -> GeneralUtils.optimizePdfWithGhostscript(Mockito.any(byte[].class)))
.thenReturn(optimizedBytes);
ResponseEntity<byte[]> expectedResponse = ResponseEntity.ok(optimizedBytes);
wr.when(
() ->
WebResponseUtils.bytesToWebResponse(
optimizedBytes, "ebook_convertedToPDF.pdf"))
.thenReturn(expectedResponse);
ResponseEntity<byte[]> response = controller.convertEbookToPdf(request);
assertSame(expectedResponse, response);
gu.verify(() -> GeneralUtils.optimizePdfWithGhostscript(Mockito.any(byte[].class)));
Mockito.verifyNoInteractions(pdfDocumentFactory);
Mockito.verify(tempFileManager).deleteTempDirectory(workingDir);
assertEquals(workingDir, deletedDir.get());
assertFalse(Files.exists(expectedInput));
assertFalse(Files.exists(expectedOutput));
}
if (Files.exists(workingDir)) {
try (Stream<Path> paths = Files.walk(workingDir)) {
paths.sorted(Comparator.reverseOrder())
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
}
}