Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ public void init() {
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");
Expand Down Expand Up @@ -449,6 +450,7 @@ public void init() {

// Calibre dependent endpoints
addEndpointToGroup("Calibre", "ebook-to-pdf");
addEndpointToGroup("Calibre", "pdf-to-epub");

// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> buildCalibreCommand(
Path inputPath, Path outputPath, boolean detectChapters, TargetDevice targetDevice) {
List<String> 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<byte[]> 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<String> 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<Path> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ public String convertEbookToPdfForm(Model model) {
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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
18 changes: 18 additions & 0 deletions app/core/src/main/resources/messages_en_GB.properties
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,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
Expand Down Expand Up @@ -1506,6 +1510,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
Expand Down
47 changes: 11 additions & 36 deletions app/core/src/main/resources/templates/convert/ebook-to-pdf.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,56 +38,31 @@
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 class="form-check mb-3">
<input id="embedAllFonts" name="embedAllFonts" type="checkbox" value="true">
<label for="embedAllFonts" th:text="#{ebookToPDF.embedAllFonts}"></label>
</div>

<div class="form-check mb-2">
<input class="form-check-input"
id="includeTableOfContents"
<div class="form-check mb-3">
<input id="includeTableOfContents"
name="includeTableOfContents"
type="checkbox"
value="true">
<label
for="includeTableOfContents"
th:text="#{ebookToPDF.includeTableOfContents}">
Add table of contents
</label>
<label for="includeTableOfContents" th:text="#{ebookToPDF.includeTableOfContents}"></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 class="form-check mb-3">
<input id="includePageNumbers" name="includePageNumbers" type="checkbox" value="true">
<label for="includePageNumbers" th:text="#{ebookToPDF.includePageNumbers}"></label>
</div>

<div class="form-check mb-3"
th:if="${@endpointConfiguration.isGroupEnabled('Ghostscript')}">
<input class="form-check-input"
id="optimizeForEbook"
<input id="optimizeForEbook"
name="optimizeForEbook"
type="checkbox"
value="true">
<label
for="optimizeForEbook"
th:text="#{ebookToPDF.optimizeForEbook}">
Optimize PDF for ebook readers (uses Ghostscript)
</label>
<label for="optimizeForEbook" th:text="#{ebookToPDF.optimizeForEbook}"></label>
</div>

<button class="btn btn-primary"
Expand Down
Loading
Loading