perf(core): Stream responses and unify temp file lifecycle across controllers (#4330)

This commit is contained in:
Ludy 2025-09-05 12:27:28 +02:00 committed by GitHub
parent 9a39aff19f
commit 9b3e2c29a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 261 additions and 187 deletions

View File

@ -0,0 +1,35 @@
package stirling.software.common.util;
import java.io.IOException;
import java.util.List;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.service.CustomPDFDocumentFactory;
@Service
@RequiredArgsConstructor
public class PDFService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
/*
* Merge multiple PDF documents into a single PDF document
*
* @param documents List of PDDocument to be merged
* @return Merged PDDocument
* @throws IOException If an error occurs during merging
*/
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
PDDocument merged = pdfDocumentFactory.createNewDocument();
PDFMergerUtility merger = new PDFMergerUtility();
for (PDDocument doc : documents) {
merger.appendDocument(merged, doc);
}
return merged;
}
}

View File

@ -4,6 +4,8 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.HttpHeaders;
@ -11,9 +13,13 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class WebResponseUtils {
public static ResponseEntity<byte[]> baosToWebResponse(
@ -64,4 +70,59 @@ public class WebResponseUtils {
return baosToWebResponse(baos, docName);
}
/**
* Convert a File to a web response (PDF default).
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @return A ResponseEntity containing the file as a resource.
*/
public static ResponseEntity<StreamingResponseBody> pdfFileToWebResponse(
TempFile outputTempFile, String docName) throws IOException {
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_PDF);
}
/**
* Convert a File to a web response (ZIP default).
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @return A ResponseEntity containing the file as a resource.
*/
public static ResponseEntity<StreamingResponseBody> zipFileToWebResponse(
TempFile outputTempFile, String docName) throws IOException {
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_OCTET_STREAM);
}
/**
* Convert a File to a web response with explicit media type (e.g., ZIP).
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @param mediaType The content type to set on the response.
* @return A ResponseEntity containing the file as a resource.
*/
public static ResponseEntity<StreamingResponseBody> fileToWebResponse(
TempFile outputTempFile, String docName, MediaType mediaType) throws IOException {
Path path = outputTempFile.getFile().toPath().normalize();
long len = Files.size(path);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(mediaType);
headers.setContentLength(len);
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + docName + "\"");
StreamingResponseBody body =
os -> {
try (os) {
Files.copy(path, os);
os.flush();
} finally {
outputTempFile.close();
}
};
return new ResponseEntity<>(body, headers, HttpStatus.OK);
}
}

View File

@ -1,6 +1,5 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
@ -27,6 +26,7 @@ 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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -37,8 +37,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfErrorUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -49,6 +50,7 @@ import stirling.software.common.util.WebResponseUtils;
public class MergeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
// Merges a list of PDDocument objects into a single PDDocument
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
@ -63,11 +65,10 @@ public class MergeController {
// Returns a comparator for sorting MultipartFile arrays based on the given sort type
private Comparator<MultipartFile> getSortComparator(String sortType) {
switch (sortType) {
case "byFileName":
return Comparator.comparing(MultipartFile::getOriginalFilename);
case "byDateModified":
return (file1, file2) -> {
return switch (sortType) {
case "byFileName" -> Comparator.comparing(MultipartFile::getOriginalFilename);
case "byDateModified" ->
(file1, file2) -> {
try {
BasicFileAttributes attr1 =
Files.readAttributes(
@ -82,8 +83,8 @@ public class MergeController {
return 0; // If there's an error, treat them as equal
}
};
case "byDateCreated":
return (file1, file2) -> {
case "byDateCreated" ->
(file1, file2) -> {
try {
BasicFileAttributes attr1 =
Files.readAttributes(
@ -98,8 +99,8 @@ public class MergeController {
return 0; // If there's an error, treat them as equal
}
};
case "byPDFTitle":
return (file1, file2) -> {
case "byPDFTitle" ->
(file1, file2) -> {
try (PDDocument doc1 = pdfDocumentFactory.load(file1);
PDDocument doc2 = pdfDocumentFactory.load(file2)) {
String title1 = doc1.getDocumentInformation().getTitle();
@ -109,10 +110,9 @@ public class MergeController {
return 0;
}
};
case "orderProvided":
default:
return (file1, file2) -> 0; // Default is the order provided
}
case "orderProvided" -> (file1, file2) -> 0; // Default is the order provided
default -> (file1, file2) -> 0; // Default is the order provided
};
}
// Adds a table of contents to the merged document using filenames as chapter titles
@ -162,10 +162,11 @@ public class MergeController {
"This endpoint merges multiple PDF files into a single PDF file. The merged"
+ " file will contain all pages from the input files in the order they were"
+ " provided. Input:PDF Output:PDF Type:MISO")
public ResponseEntity<byte[]> mergePdfs(@ModelAttribute MergePdfsRequest request)
public ResponseEntity<StreamingResponseBody> mergePdfs(@ModelAttribute MergePdfsRequest request)
throws IOException {
List<File> filesToDelete = new ArrayList<>(); // List of temporary files to delete
File mergedTempFile = null;
TempFile mergedTempFile = null;
TempFile outputTempFile = null;
PDDocument mergedDocument = null;
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
@ -183,14 +184,14 @@ public class MergeController {
for (MultipartFile multipartFile : files) {
totalSize += multipartFile.getSize();
File tempFile =
GeneralUtils.convertMultipartFileToFile(
tempFileManager.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
mergerUtility.addSource(tempFile); // Add source file to the merger utility
}
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
mergedTempFile = new TempFile(tempFileManager, ".pdf");
mergerUtility.setDestinationFileName(mergedTempFile.getFile().getAbsolutePath());
try {
mergerUtility.mergeDocuments(
@ -205,7 +206,7 @@ public class MergeController {
}
// Load the merged PDF document
mergedDocument = pdfDocumentFactory.load(mergedTempFile);
mergedDocument = pdfDocumentFactory.load(mergedTempFile.getFile());
// Remove signatures if removeCertSign is true
if (removeCertSign) {
@ -214,7 +215,7 @@ public class MergeController {
if (acroForm != null) {
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(field -> field instanceof PDSignatureField)
.filter(PDSignatureField.class::isInstance)
.toList();
if (!fieldsToRemove.isEmpty()) {
@ -230,16 +231,15 @@ public class MergeController {
addTableOfContents(mergedDocument, files);
}
// Save the modified document to a new ByteArrayOutputStream
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mergedDocument.save(baos);
// Save the modified document to a temporary file
outputTempFile = new TempFile(tempFileManager, ".pdf");
mergedDocument.save(outputTempFile.getFile());
String mergedFileName =
files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_merged_unsigned.pdf";
return WebResponseUtils.baosToWebResponse(
baos, mergedFileName); // Return the modified PDF
return WebResponseUtils.pdfFileToWebResponse(
outputTempFile, mergedFileName); // Return the modified PDF as stream
} catch (Exception ex) {
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
log.warn("Corrupted PDF detected in merge pdf process: {}", ex.getMessage());
@ -252,12 +252,10 @@ public class MergeController {
mergedDocument.close(); // Close the merged document
}
for (File file : filesToDelete) {
if (file != null) {
Files.deleteIfExists(file.toPath()); // Delete temporary files
}
tempFileManager.deleteTempFile(file); // Delete temporary files
}
if (mergedTempFile != null) {
Files.deleteIfExists(mergedTempFile.toPath());
mergedTempFile.close();
}
}
}

View File

@ -30,6 +30,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -40,6 +42,7 @@ import stirling.software.common.util.WebResponseUtils;
public class SplitPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/split-pages")
@Operation(
@ -55,8 +58,11 @@ public class SplitPDFController {
PDDocument document = null;
Path zipFile = null;
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
String filename;
TempFile outputTempFile = null;
try {
outputTempFile = new TempFile(tempFileManager, ".zip");
MultipartFile file = request.getFileInput();
String pages = request.getPageNumbers();
@ -105,12 +111,11 @@ public class SplitPDFController {
// closing the original document
document.close();
zipFile = Files.createTempFile("split_documents", ".zip");
String filename =
filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
// loop through the split documents and write them to the zip file
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
String fileName = filename + "_" + (i + 1) + ".pdf";
@ -125,19 +130,13 @@ public class SplitPDFController {
log.debug("Wrote split document {} to zip file", fileName);
}
} catch (Exception e) {
log.error("Failed writing to zip", e);
throw e;
}
log.debug("Successfully created zip file with split documents: {}", zipFile.toString());
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
// return the Resource in the response
log.debug(
"Successfully created zip file with split documents: {}",
outputTempFile.getPath());
byte[] data = Files.readAllBytes(outputTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
try {
// Close the main document
@ -152,9 +151,9 @@ public class SplitPDFController {
}
}
// Delete temporary zip file
if (zipFile != null) {
Files.deleteIfExists(zipFile);
// Close the output temporary file
if (outputTempFile != null) {
outputTempFile.close();
}
} catch (Exception e) {
log.error("Error while cleaning up resources", e);

View File

@ -2,8 +2,8 @@ package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
@ -17,13 +17,13 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.util.Matrix;
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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
@ -33,6 +33,9 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.PDFService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -42,6 +45,8 @@ import stirling.software.common.util.WebResponseUtils;
public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final PDFService pdfService;
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@ -50,8 +55,8 @@ public class SplitPdfBySectionsController {
"Split each page of a PDF into smaller sections based on the user's choice"
+ " (halves, thirds, quarters, etc.), both vertically and horizontally."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
throws Exception {
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
MultipartFile file = request.getFileInput();
@ -67,10 +72,14 @@ public class SplitPdfBySectionsController {
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
if (merge) {
MergeController mergeController = new MergeController(pdfDocumentFactory);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mergeController.mergeDocuments(splitDocuments).save(baos);
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), filename + "_split.pdf");
TempFile tempFile = new TempFile(tempFileManager, ".pdf");
try (PDDocument merged = pdfService.mergeDocuments(splitDocuments);
OutputStream out = Files.newOutputStream(tempFile.getPath())) {
merged.save(out);
for (PDDocument d : splitDocuments) d.close();
sourceDocument.close();
}
return WebResponseUtils.pdfFileToWebResponse(tempFile, filename + "_split.pdf");
}
for (PDDocument doc : splitDocuments) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@ -81,10 +90,9 @@ public class SplitPdfBySectionsController {
sourceDocument.close();
Path zipFile = Files.createTempFile("split_documents", ".zip");
byte[] data;
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
int pageNum = 1;
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
@ -98,15 +106,8 @@ public class SplitPdfBySectionsController {
if (sectionNum == horiz * verti) pageNum++;
}
zipOut.finish();
data = Files.readAllBytes(zipFile);
return WebResponseUtils.bytesToWebResponse(
data, filename + "_split.zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
Files.deleteIfExists(zipFile);
}
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_split.zip");
}
public List<PDDocument> splitPdfPages(

View File

@ -28,6 +28,8 @@ import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -38,6 +40,7 @@ import stirling.software.common.util.WebResponseUtils;
public class SplitPdfBySizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@PostMapping(value = "/split-by-size-or-count", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@ -54,15 +57,14 @@ public class SplitPdfBySizeController {
log.debug("Starting PDF split process with request: {}", request);
MultipartFile file = request.getFileInput();
Path zipFile = Files.createTempFile("split_documents", ".zip");
log.debug("Created temporary zip file: {}", zipFile);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
log.debug("Base filename for output: {}", filename);
byte[] data = null;
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
Path zipFile = zipTempFile.getPath();
log.debug("Created temporary zip file: {}", zipFile);
try {
log.debug("Reading input file bytes");
byte[] pdfBytes = file.getBytes();
@ -102,41 +104,21 @@ public class SplitPdfBySizeController {
"Invalid argument: {0}",
"split type: " + type);
}
log.debug("PDF splitting completed successfully");
} catch (Exception e) {
ExceptionUtils.logException("PDF document loading or processing", e);
throw e;
}
} catch (IOException e) {
log.error("Error creating or writing to ZIP file", e);
throw e;
}
byte[] data = Files.readAllBytes(zipFile);
log.debug("Successfully read {} bytes from ZIP file", data.length);
log.debug("Returning response with {} bytes of data", data.length);
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
ExceptionUtils.logException("PDF splitting process", e);
throw e; // Re-throw to ensure proper error response
} finally {
try {
log.debug("Reading ZIP file data");
data = Files.readAllBytes(zipFile);
log.debug("Successfully read {} bytes from ZIP file", data.length);
} catch (IOException e) {
log.error("Error reading ZIP file data", e);
}
try {
log.debug("Deleting temporary ZIP file");
boolean deleted = Files.deleteIfExists(zipFile);
log.debug("Temporary ZIP file deleted: {}", deleted);
} catch (IOException e) {
log.error("Error deleting temporary ZIP file", e);
}
}
log.debug("Returning response with {} bytes of data", data != null ? data.length : 0);
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
private void handleSplitBySize(

View File

@ -6,7 +6,6 @@ import java.awt.image.DataBufferInt;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@ -36,6 +35,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.AutoSplitPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@ -53,6 +54,7 @@ public class AutoSplitPdfController {
"https://stirlingpdf.com"));
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private static String decodeQRCode(BufferedImage bufferedImage) {
LuminanceSource source;
@ -117,10 +119,10 @@ public class AutoSplitPdfController {
PDDocument document = null;
List<PDDocument> splitDocuments = new ArrayList<>();
Path zipFile = null;
byte[] data = null;
TempFile outputTempFile = null;
try {
outputTempFile = new TempFile(tempFileManager, ".zip");
document = pdfDocumentFactory.load(file.getInputStream());
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
@ -152,12 +154,12 @@ public class AutoSplitPdfController {
// Remove split documents that have no pages
splitDocuments.removeIf(pdDocument -> pdDocument.getNumberOfPages() == 0);
zipFile = Files.createTempFile("split_documents", ".zip");
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(zipFile))) {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
for (int i = 0; i < splitDocuments.size(); i++) {
String fileName = filename + "_" + (i + 1) + ".pdf";
PDDocument splitDocument = splitDocuments.get(i);
@ -173,10 +175,10 @@ public class AutoSplitPdfController {
}
}
data = Files.readAllBytes(zipFile);
byte[] data = Files.readAllBytes(outputTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} catch (Exception e) {
log.error("Error in auto split", e);
throw e;
@ -198,12 +200,8 @@ public class AutoSplitPdfController {
}
}
if (zipFile != null) {
try {
Files.deleteIfExists(zipFile);
} catch (IOException e) {
log.error("Error deleting temporary zip file", e);
}
if (outputTempFile != null) {
outputTempFile.close();
}
}
}