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 {