From 97f3b88222cea0ca4b751b8770679b91d5d490f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:02:50 +0100 Subject: [PATCH] feat(pdf-EPUB): add PDF to EPUB/AZW3 conversion functionality via Calibre (#4947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes This PR introduces a new conversion tool allowing users to convert PDF documents into EPUB format. This is particularly useful for reading documents on e-readers (like Kindles or Kobos) where standard PDFs often suffer from fixed formatting and unreadable text sizes. The implementation leverages the existing **Calibre** integration (`ebook-convert`) to produce reflowable e-books with specific optimizations for layout and chapter structure. **Backend Implementation** * Added `ConvertPDFToEpubController` to handle the conversion workflow. * Created `ConvertPdfToEpubRequest` to support new conversion parameters (Device profile and Chapter detection). * Integrated standard Stirling-PDF temporary file management and process execution patterns. **Frontend & UI** * Added a new view `pdf-to-epub.html` containing the upload form and configuration options. * Updated `navElements.html` and `messages.properties` to expose the tool in the navigation menu under the "Convert" group. * Minor cleanup of HTML formatting in the existing `ebook-to-pdf` template for consistency. **Configuration & Testing** * Registered the `pdf-to-epub` endpoint in `EndpointConfiguration`, placing it under the **Calibre** dependency group. * Added comprehensive unit tests covering command generation, parameter handling, and temporary file cleanup. The conversion process utilizes specific calibre, `ebook-convert` flags to ensure high-quality output: * **Heuristic Processing** (`--enable-heuristics`): Automatically detects and fixes common PDF scanning issues, such as broken lines, hyphens at line ends, and inconsistent paragraph spacing. * **CSS Filtering** (`--filter-css`): Strips hardcoded styling (font families, fixed margins, colors) from the PDF. This ensures the resulting EPUB respects the user's e-reader settings (font size, dark mode, etc.). * **Smart Chapter Detection** (`--chapter`): Optionally uses an XPath expression (`//h:*[re:test(., '\\s*Chapter\\s+', 'i')]`) to detect headers and insert proper page breaks in the EPUB structure. * **Device Optimization Profiles**: * **Tablet/Phone:** Uses the default profile to maintain image resolution and color. * **Kindle/E-Ink:** Uses a specific profile to resize images and optimize contrast for grayscale screens. --- ## 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) - [ ] 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 --- .../SPDF/config/EndpointConfiguration.java | 2 + .../ConvertPDFToEpubController.java | 204 +++++++++++ .../web/ConverterWebController.java | 11 + .../converters/ConvertPdfToEpubRequest.java | 58 ++++ .../main/resources/messages_en_GB.properties | 18 + .../templates/convert/ebook-to-pdf.html | 47 +-- .../templates/convert/pdf-to-epub.html | 87 +++++ .../templates/fragments/navElements.html | 6 + .../ConvertPDFToEpubControllerTest.java | 328 ++++++++++++++++++ .../web/ConverterWebControllerTest.java | 36 ++ 10 files changed, 761 insertions(+), 36 deletions(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubController.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToEpubRequest.java create mode 100644 app/core/src/main/resources/templates/convert/pdf-to-epub.html create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubControllerTest.java diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 74d71825e..11cb758ee 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -258,6 +258,7 @@ public class EndpointConfiguration { addEndpointToGroup("Convert", "url-to-pdf"); addEndpointToGroup("Convert", "markdown-to-pdf"); addEndpointToGroup("Convert", "ebook-to-pdf"); + addEndpointToGroup("Convert", "pdf-to-epub"); addEndpointToGroup("Convert", "pdf-to-csv"); addEndpointToGroup("Convert", "pdf-to-markdown"); addEndpointToGroup("Convert", "eml-to-pdf"); @@ -449,6 +450,7 @@ public class EndpointConfiguration { // Calibre dependent endpoints addEndpointToGroup("Calibre", "ebook-to-pdf"); + addEndpointToGroup("Calibre", "pdf-to-epub"); // Pdftohtml dependent endpoints addEndpointToGroup("Pdftohtml", "pdf-to-html"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubController.java new file mode 100644 index 000000000..8e152427c --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubController.java @@ -0,0 +1,204 @@ +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 org.apache.commons.io.FilenameUtils; +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.ConvertPdfToEpubRequest; +import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat; +import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice; +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 ConvertPDFToEpubController { + + private static final String CALIBRE_GROUP = "Calibre"; + private static final String DEFAULT_EXTENSION = "pdf"; + private static final String FILTERED_CSS = + "font-family,color,background-color,margin-left,margin-right"; + private static final String SMART_CHAPTER_EXPRESSION = + "//h:*[re:test(., '\\s*Chapter\\s+', 'i')]"; + + private final TempFileManager tempFileManager; + private final EndpointConfiguration endpointConfiguration; + + private static List buildCalibreCommand( + Path inputPath, Path outputPath, boolean detectChapters, TargetDevice targetDevice) { + List command = new ArrayList<>(); + command.add("ebook-convert"); + command.add(inputPath.toString()); + command.add(outputPath.toString()); + + // Golden defaults + command.add("--enable-heuristics"); + command.add("--insert-blank-line"); + command.add("--filter-css"); + command.add(FILTERED_CSS); + + if (detectChapters) { + command.add("--chapter"); + command.add(SMART_CHAPTER_EXPRESSION); + } + + if (targetDevice != null) { + command.add("--output-profile"); + command.add(targetDevice.getCalibreProfile()); + } + + return command; + } + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub") + @Operation( + summary = "Convert PDF to EPUB/AZW3", + description = + "Convert a PDF file to a high-quality EPUB or AZW3 ebook using Calibre. Input:PDF" + + " Output:EPUB/AZW3 Type:SISO") + public ResponseEntity convertPdfToEpub(@ModelAttribute ConvertPdfToEpubRequest request) + throws Exception { + + if (!endpointConfiguration.isGroupEnabled(CALIBRE_GROUP)) { + throw new IllegalStateException( + "Calibre support is disabled. Enable the Calibre group or install Calibre to use" + + " this feature."); + } + + MultipartFile inputFile = request.getFileInput(); + if (inputFile == null || inputFile.isEmpty()) { + throw new IllegalArgumentException("No input file provided"); + } + + boolean detectChapters = !Boolean.FALSE.equals(request.getDetectChapters()); + TargetDevice targetDevice = + request.getTargetDevice() == null + ? TargetDevice.TABLET_PHONE_IMAGES + : request.getTargetDevice(); + OutputFormat outputFormat = + request.getOutputFormat() == null ? OutputFormat.EPUB : request.getOutputFormat(); + + String originalFilename = Filenames.toSimpleFileName(inputFile.getOriginalFilename()); + if (originalFilename == null || originalFilename.isBlank()) { + originalFilename = "document." + DEFAULT_EXTENSION; + } + + String extension = FilenameUtils.getExtension(originalFilename); + if (extension.isBlank()) { + throw new IllegalArgumentException("Unable to determine file type"); + } + + if (!DEFAULT_EXTENSION.equalsIgnoreCase(extension)) { + throw new IllegalArgumentException("Input file must be a PDF"); + } + + String baseName = FilenameUtils.getBaseName(originalFilename); + if (baseName == null || baseName.isBlank()) { + baseName = "document"; + } + + Path workingDirectory = null; + Path inputPath = null; + Path outputPath = null; + + try { + workingDirectory = tempFileManager.createTempDirectory(); + inputPath = workingDirectory.resolve(baseName + "." + DEFAULT_EXTENSION); + outputPath = workingDirectory.resolve(baseName + "." + outputFormat.getExtension()); + + try (InputStream inputStream = inputFile.getInputStream()) { + Files.copy(inputStream, inputPath, StandardCopyOption.REPLACE_EXISTING); + } + + List command = + buildCalibreCommand(inputPath, outputPath, detectChapters, targetDevice); + 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 " + outputFormat.name() + " output"); + } + + String outputFilename = + GeneralUtils.generateFilename( + originalFilename, + "_convertedTo" + + outputFormat.name() + + "." + + outputFormat.getExtension()); + + byte[] outputBytes = Files.readAllBytes(outputPath); + MediaType mediaType = MediaType.valueOf(outputFormat.getMediaType()); + return WebResponseUtils.bytesToWebResponse(outputBytes, outputFilename, mediaType); + } finally { + cleanupTempFiles(workingDirectory, inputPath, outputPath); + } + } + + private void cleanupTempFiles(Path workingDirectory, Path inputPath, Path outputPath) { + if (workingDirectory == null) { + return; + } + List pathsToDelete = new ArrayList<>(); + if (inputPath != null) { + pathsToDelete.add(inputPath); + } + if (outputPath != null) { + pathsToDelete.add(outputPath); + } + for (Path path : pathsToDelete) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("Failed to delete temporary file: {}", path, e); + } + } + + try { + tempFileManager.deleteTempDirectory(workingDirectory); + } catch (Exception e) { + log.warn("Failed to delete temporary directory: {}", workingDirectory, e); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java index ef0d840b2..d5c05ff68 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java @@ -54,6 +54,17 @@ public class ConverterWebController { return "convert/ebook-to-pdf"; } + @GetMapping("/pdf-to-epub") + @Hidden + public String convertPdfToEpubForm(Model model) { + if (!ApplicationContextProvider.getBean(EndpointConfiguration.class) + .isEndpointEnabled("pdf-to-epub")) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + model.addAttribute("currentPage", "pdf-to-epub"); + return "convert/pdf-to-epub"; + } + @GetMapping("/pdf-to-cbr") @Hidden public String convertPdfToCbrForm(Model model) { diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToEpubRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToEpubRequest.java new file mode 100644 index 000000000..f9da52011 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPdfToEpubRequest.java @@ -0,0 +1,58 @@ +package stirling.software.SPDF.model.api.converters; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import stirling.software.common.model.api.PDFFile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ConvertPdfToEpubRequest extends PDFFile { + + @Schema( + description = "Detect headings that look like chapters and insert EPUB page breaks.", + allowableValues = {"true", "false"}, + defaultValue = "true") + private Boolean detectChapters = Boolean.TRUE; + + @Schema( + description = "Choose an output profile optimized for the reader device.", + allowableValues = {"TABLET_PHONE_IMAGES", "KINDLE_EINK_TEXT"}, + defaultValue = "TABLET_PHONE_IMAGES") + private TargetDevice targetDevice = TargetDevice.TABLET_PHONE_IMAGES; + + @Schema( + description = "Choose the output format for the ebook.", + allowableValues = {"EPUB", "AZW3"}, + defaultValue = "EPUB") + private OutputFormat outputFormat = OutputFormat.EPUB; + + @Getter + public enum TargetDevice { + TABLET_PHONE_IMAGES("tablet"), + KINDLE_EINK_TEXT("kindle"); + + private final String calibreProfile; + + TargetDevice(String calibreProfile) { + this.calibreProfile = calibreProfile; + } + } + + @Getter + public enum OutputFormat { + EPUB("epub", "application/epub+zip"), + AZW3("azw3", "application/vnd.amazon.ebook"); + + private final String extension; + private final String mediaType; + + OutputFormat(String extension, String mediaType) { + this.extension = extension; + this.mediaType = mediaType; + } + } +} diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index a3e0f67fd..54bb0d4b2 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -706,6 +706,10 @@ 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.pdfToEpub.title=PDF to EPUB/AZW3 +home.pdfToEpub.desc=Convert PDF files into EPUB or AZW3 ebooks optimised for e-readers using Calibre. +pdfToEpub.tags=conversion,ebook,epub,azw3,calibre + 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 @@ -1592,6 +1596,20 @@ 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. +#pdfToEpub +pdfToEpub.title=PDF to EPUB/AZW3 +pdfToEpub.header=PDF to EPUB/AZW3 +pdfToEpub.submit=Convert +pdfToEpub.selectText=Select PDF file +pdfToEpub.outputFormat=Output format +pdfToEpub.outputFormat.epub=EPUB +pdfToEpub.outputFormat.azw3=AZW3 +pdfToEpub.detectChapters=Detect chapters and insert automatic breaks +pdfToEpub.targetDevice=Target device +pdfToEpub.targetDevice.tablet=Tablet / Phone (keeps images high quality) +pdfToEpub.targetDevice.kindle=Kindle / E-Ink (text-focused, smaller images) +pdfToEpub.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 diff --git a/app/core/src/main/resources/templates/convert/ebook-to-pdf.html b/app/core/src/main/resources/templates/convert/ebook-to-pdf.html index 9c2614fcf..047e5c02d 100644 --- a/app/core/src/main/resources/templates/convert/ebook-to-pdf.html +++ b/app/core/src/main/resources/templates/convert/ebook-to-pdf.html @@ -38,56 +38,31 @@ th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='.epub,.mobi,.azw3,.fb2,.txt,.docx', inputText=#{ebookToPDF.selectText})}"> -
- - +
+ +
-
- + - +
-
- - +
+ +
- - +
+ +
+
+ + + + + + + diff --git a/app/core/src/main/resources/templates/fragments/navElements.html b/app/core/src/main/resources/templates/fragments/navElements.html index 3bb9ea25c..84acb22dd 100644 --- a/app/core/src/main/resources/templates/fragments/navElements.html +++ b/app/core/src/main/resources/templates/fragments/navElements.html @@ -89,6 +89,9 @@
+
+
@@ -163,6 +166,9 @@
+
+
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubControllerTest.java new file mode 100644 index 000000000..82aeed070 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToEpubControllerTest.java @@ -0,0 +1,328 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.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.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest; +import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat; +import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice; +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; + +@ExtendWith(MockitoExtension.class) +class ConvertPDFToEpubControllerTest { + + private static final MediaType EPUB_MEDIA_TYPE = MediaType.valueOf("application/epub+zip"); + + @Mock private TempFileManager tempFileManager; + @Mock private EndpointConfiguration endpointConfiguration; + + @InjectMocks private ConvertPDFToEpubController controller; + + @Test + void convertPdfToEpub_buildsGoldenCommandAndCleansUp() throws Exception { + when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true); + + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "novel.pdf", "application/pdf", "content".getBytes()); + + ConvertPdfToEpubRequest request = new ConvertPdfToEpubRequest(); + request.setFileInput(pdfFile); + + Path workingDir = Files.createTempDirectory("pdf-epub-test-"); + when(tempFileManager.createTempDirectory()).thenReturn(workingDir); + + AtomicReference deletedDir = new AtomicReference<>(); + doAnswer( + invocation -> { + Path dir = invocation.getArgument(0); + deletedDir.set(dir); + if (Files.exists(dir)) { + try (Stream 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 pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class)) { + + ProcessExecutor executor = mock(ProcessExecutor.class); + pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor); + + ProcessExecutorResult execResult = mock(ProcessExecutorResult.class); + when(execResult.getRc()).thenReturn(0); + + @SuppressWarnings("unchecked") + ArgumentCaptor> commandCaptor = ArgumentCaptor.forClass(List.class); + Path expectedInput = workingDir.resolve("novel.pdf"); + Path expectedOutput = workingDir.resolve("novel.epub"); + + when(executor.runCommandWithOutputHandling( + commandCaptor.capture(), eq(workingDir.toFile()))) + .thenAnswer( + invocation -> { + Files.writeString(expectedOutput, "epub"); + return execResult; + }); + + gu.when(() -> GeneralUtils.generateFilename("novel.pdf", "_convertedToEPUB.epub")) + .thenReturn("novel_convertedToEPUB.epub"); + ResponseEntity response = controller.convertPdfToEpub(request); + + List command = commandCaptor.getValue(); + assertEquals(11, command.size()); + assertEquals("ebook-convert", command.get(0)); + assertEquals(expectedInput.toString(), command.get(1)); + assertEquals(expectedOutput.toString(), command.get(2)); + assertTrue(command.contains("--enable-heuristics")); + assertTrue(command.contains("--insert-blank-line")); + assertTrue(command.contains("--filter-css")); + assertTrue( + command.contains( + "font-family,color,background-color,margin-left,margin-right")); + assertTrue(command.contains("--chapter")); + assertTrue(command.stream().anyMatch(arg -> arg.contains("Chapter\\s+"))); + assertTrue(command.contains("--output-profile")); + assertTrue(command.contains(TargetDevice.TABLET_PHONE_IMAGES.getCalibreProfile())); + + assertEquals(EPUB_MEDIA_TYPE, response.getHeaders().getContentType()); + assertEquals( + "novel_convertedToEPUB.epub", + response.getHeaders().getContentDisposition().getFilename()); + assertEquals("epub", new String(response.getBody(), StandardCharsets.UTF_8)); + + verify(tempFileManager).deleteTempDirectory(workingDir); + assertEquals(workingDir, deletedDir.get()); + } finally { + deleteIfExists(workingDir); + } + } + + @Test + void convertPdfToEpub_respectsOptions() throws Exception { + when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true); + + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "story.pdf", "application/pdf", "content".getBytes()); + + ConvertPdfToEpubRequest request = new ConvertPdfToEpubRequest(); + request.setFileInput(pdfFile); + request.setDetectChapters(false); + request.setTargetDevice(TargetDevice.KINDLE_EINK_TEXT); + + Path workingDir = Files.createTempDirectory("pdf-epub-options-test-"); + when(tempFileManager.createTempDirectory()).thenReturn(workingDir); + + doAnswer( + invocation -> { + Path dir = invocation.getArgument(0); + if (Files.exists(dir)) { + try (Stream 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 pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class)) { + + ProcessExecutor executor = mock(ProcessExecutor.class); + pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor); + + ProcessExecutorResult execResult = mock(ProcessExecutorResult.class); + when(execResult.getRc()).thenReturn(0); + + @SuppressWarnings("unchecked") + ArgumentCaptor> commandCaptor = ArgumentCaptor.forClass(List.class); + Path expectedOutput = workingDir.resolve("story.epub"); + + when(executor.runCommandWithOutputHandling( + commandCaptor.capture(), eq(workingDir.toFile()))) + .thenAnswer( + invocation -> { + Files.writeString(expectedOutput, "epub"); + return execResult; + }); + + gu.when(() -> GeneralUtils.generateFilename("story.pdf", "_convertedToEPUB.epub")) + .thenReturn("story_convertedToEPUB.epub"); + ResponseEntity response = controller.convertPdfToEpub(request); + + List command = commandCaptor.getValue(); + assertTrue(command.stream().noneMatch(arg -> "--chapter".equals(arg))); + assertTrue(command.contains("--output-profile")); + assertTrue(command.contains(TargetDevice.KINDLE_EINK_TEXT.getCalibreProfile())); + assertTrue(command.contains("--filter-css")); + assertTrue( + command.contains( + "font-family,color,background-color,margin-left,margin-right")); + assertTrue(command.size() >= 9); + + assertEquals(EPUB_MEDIA_TYPE, response.getHeaders().getContentType()); + assertEquals( + "story_convertedToEPUB.epub", + response.getHeaders().getContentDisposition().getFilename()); + assertEquals("epub", new String(response.getBody(), StandardCharsets.UTF_8)); + } finally { + deleteIfExists(workingDir); + } + } + + @Test + void convertPdfToAzw3_buildsCorrectCommandAndOutput() throws Exception { + when(endpointConfiguration.isGroupEnabled("Calibre")).thenReturn(true); + + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "book.pdf", "application/pdf", "content".getBytes()); + + ConvertPdfToEpubRequest request = new ConvertPdfToEpubRequest(); + request.setFileInput(pdfFile); + request.setOutputFormat(OutputFormat.AZW3); + request.setDetectChapters(false); + request.setTargetDevice(TargetDevice.KINDLE_EINK_TEXT); + + Path workingDir = Files.createTempDirectory("pdf-azw3-test-"); + when(tempFileManager.createTempDirectory()).thenReturn(workingDir); + + doAnswer( + invocation -> { + Path dir = invocation.getArgument(0); + if (Files.exists(dir)) { + try (Stream 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 pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class)) { + + ProcessExecutor executor = mock(ProcessExecutor.class); + pe.when(() -> ProcessExecutor.getInstance(Processes.CALIBRE)).thenReturn(executor); + + ProcessExecutorResult execResult = mock(ProcessExecutorResult.class); + when(execResult.getRc()).thenReturn(0); + + @SuppressWarnings("unchecked") + ArgumentCaptor> commandCaptor = ArgumentCaptor.forClass(List.class); + Path expectedInput = workingDir.resolve("book.pdf"); + Path expectedOutput = workingDir.resolve("book.azw3"); + + when(executor.runCommandWithOutputHandling( + commandCaptor.capture(), eq(workingDir.toFile()))) + .thenAnswer( + invocation -> { + Files.writeString(expectedOutput, "azw3"); + return execResult; + }); + + gu.when(() -> GeneralUtils.generateFilename("book.pdf", "_convertedToAZW3.azw3")) + .thenReturn("book_convertedToAZW3.azw3"); + ResponseEntity response = controller.convertPdfToEpub(request); + + List command = commandCaptor.getValue(); + assertEquals("ebook-convert", command.get(0)); + assertEquals(expectedInput.toString(), command.get(1)); + assertEquals(expectedOutput.toString(), command.get(2)); + assertTrue(command.contains("--enable-heuristics")); + assertTrue(command.contains("--insert-blank-line")); + assertTrue(command.contains("--filter-css")); + assertTrue(command.stream().noneMatch(arg -> "--chapter".equals(arg))); + assertTrue(command.contains("--output-profile")); + assertTrue(command.contains(TargetDevice.KINDLE_EINK_TEXT.getCalibreProfile())); + + assertEquals( + MediaType.valueOf("application/vnd.amazon.ebook"), + response.getHeaders().getContentType()); + assertEquals( + "book_convertedToAZW3.azw3", + response.getHeaders().getContentDisposition().getFilename()); + assertEquals("azw3", new String(response.getBody(), StandardCharsets.UTF_8)); + + verify(tempFileManager).deleteTempDirectory(workingDir); + } finally { + deleteIfExists(workingDir); + } + } + + private void deleteIfExists(Path directory) throws IOException { + if (directory == null || !Files.exists(directory)) { + return; + } + try (Stream paths = Files.walk(directory)) { + paths.sorted(Comparator.reverseOrder()) + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + }); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java index 32a93b581..da73cf83c 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java @@ -110,6 +110,42 @@ class ConverterWebControllerTest { } } + @Nested + @DisplayName("PDF to EPUB endpoint tests") + class PdfToEpubTests { + + @Test + @DisplayName("Should return 404 when endpoint disabled") + void shouldReturn404WhenDisabled() throws Exception { + try (MockedStatic acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) { + EndpointConfiguration endpointConfig = mock(EndpointConfiguration.class); + when(endpointConfig.isEndpointEnabled(eq("pdf-to-epub"))).thenReturn(false); + acp.when(() -> ApplicationContextProvider.getBean(EndpointConfiguration.class)) + .thenReturn(endpointConfig); + + mockMvc.perform(get("/pdf-to-epub")).andExpect(status().isNotFound()); + } + } + + @Test + @DisplayName("Should return OK when endpoint enabled") + void shouldReturnOkWhenEnabled() throws Exception { + try (MockedStatic acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) { + EndpointConfiguration endpointConfig = mock(EndpointConfiguration.class); + when(endpointConfig.isEndpointEnabled(eq("pdf-to-epub"))).thenReturn(true); + acp.when(() -> ApplicationContextProvider.getBean(EndpointConfiguration.class)) + .thenReturn(endpointConfig); + + mockMvc.perform(get("/pdf-to-epub")) + .andExpect(status().isOk()) + .andExpect(view().name("convert/pdf-to-epub")) + .andExpect(model().attribute("currentPage", "pdf-to-epub")); + } + } + } + @Test @DisplayName("Should handle pdf-to-img with default maxDPI=500") void shouldHandlePdfToImgWithDefaultMaxDpi() throws Exception {