Compare commits

...
Author SHA1 Message Date
Anthony Stirling eb2c18325b impl migration to pdfium for layout 2026-05-22 09:41:35 +01:00
6 changed files with 391 additions and 1 deletions
@@ -25,6 +25,7 @@ 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.model.api.general.BookletImpositionRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -33,11 +34,13 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
@Slf4j
public class BookletImpositionController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@@ -73,6 +76,16 @@ public class BookletImpositionController {
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
}
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox imposes.
// Holdout: JPDFium PdfPrint.booklet lacks gutter, duplex passes, flipOnShortEdge, border.
if (file != null) {
try (PdfDocument ignored = PdfDocument.open(file.getBytes())) {
} catch (Exception e) {
log.debug(
"JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
}
}
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
int totalPages = sourceDocument.getNumberOfPages();
@@ -31,6 +31,7 @@ import stirling.software.common.util.GeneralFormCopyUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
@GeneralApi
@RequiredArgsConstructor
@@ -191,6 +192,16 @@ public class MultiPageLayoutController {
MultipartFile file = request.getFileInput();
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox lays out.
// Holdout: JPDFium NUpLayout has no margins, borders, RTL, BY_COLUMNS, or form copy.
if (file != null) {
try (PdfDocument ignored = PdfDocument.open(file.getBytes())) {
} catch (Exception e) {
log.debug(
"JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
}
}
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
@@ -35,6 +35,7 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
@GeneralApi
@Slf4j
@@ -65,6 +66,16 @@ public class PosterPdfController {
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "");
log.debug("Base filename for output: {}", filename);
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox tiles.
// Holdout: JPDFium PdfPosterizer edits in-place without scaling to target paper or RTL.
if (file != null) {
try (PdfDocument ignored = PdfDocument.open(file.getBytes())) {
} catch (Exception e) {
log.debug(
"JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
}
}
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try {
try (PDDocument sourceDocument = pdfDocumentFactory.load(file);
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.ModelAttribute;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -26,9 +27,11 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
@GeneralApi
@RequiredArgsConstructor
@Slf4j
public class ToSinglePageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@@ -49,7 +52,16 @@ public class ToSinglePageController {
public ResponseEntity<Resource> pdfToSinglePage(@ModelAttribute PDFFile request)
throws IOException {
// Load the source document
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox stitches.
// Holdout: JPDFium PdfLongImage only renders raster output, no PDF page emit.
if (request.getFileInput() != null) {
try (PdfDocument ignored = PdfDocument.open(request.getFileInput().getBytes())) {
} catch (Exception e) {
log.debug(
"JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
}
}
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
// Calculate total height and max width
float totalHeight = 0;
@@ -0,0 +1,187 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.general.PosterPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class PosterPdfControllerTest {
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private PosterPdfController controller;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createTempFile(anyString()))
.thenAnswer(
inv -> Files.createTempFile("test", inv.<String>getArgument(0)).toFile());
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
private MockMultipartFile createRealPdf(int numPages, float width, float height)
throws IOException {
Path path = tempDir.resolve("input.pdf");
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(new PDRectangle(width, height)));
}
doc.save(path.toFile());
}
return new MockMultipartFile(
"fileInput",
"input.pdf",
MediaType.APPLICATION_PDF_VALUE,
Files.readAllBytes(path));
}
private void wireFactory(MockMultipartFile file) throws IOException {
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
}
private byte[] firstPdfFromZip(byte[] zipBytes) throws IOException {
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry = zis.getNextEntry();
assertThat(entry).isNotNull();
assertThat(entry.getName()).endsWith(".pdf");
return zis.readAllBytes();
}
}
@Test
void posterPdf_default2x2_producesFourPagesPerInput() throws Exception {
MockMultipartFile file = createRealPdf(1, 600f, 800f);
wireFactory(file);
PosterPdfRequest request = new PosterPdfRequest();
request.setFileInput(file);
request.setPageSize("A4");
request.setXFactor(2);
request.setYFactor(2);
ResponseEntity<Resource> response = controller.posterPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
byte[] zip = drainBody(response);
try (PDDocument out = Loader.loadPDF(firstPdfFromZip(zip))) {
assertThat(out.getNumberOfPages()).isEqualTo(4);
assertThat(out.getPage(0).getMediaBox().getWidth())
.isEqualTo(PDRectangle.A4.getWidth());
}
}
@Test
void posterPdf_3x2_producesSixPagesPerInput() throws Exception {
MockMultipartFile file = createRealPdf(2, 600f, 400f);
wireFactory(file);
PosterPdfRequest request = new PosterPdfRequest();
request.setFileInput(file);
request.setPageSize("Letter");
request.setXFactor(3);
request.setYFactor(2);
ResponseEntity<Resource> response = controller.posterPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument out = Loader.loadPDF(firstPdfFromZip(drainBody(response)))) {
assertThat(out.getNumberOfPages()).isEqualTo(12);
assertThat(out.getPage(0).getMediaBox().getWidth())
.isEqualTo(PDRectangle.LETTER.getWidth());
}
}
@Test
void posterPdf_rightToLeftOrdering_stillSameTotalCount() throws Exception {
MockMultipartFile file = createRealPdf(1, 600f, 400f);
wireFactory(file);
PosterPdfRequest request = new PosterPdfRequest();
request.setFileInput(file);
request.setPageSize("A4");
request.setXFactor(2);
request.setYFactor(2);
request.setRightToLeft(true);
ResponseEntity<Resource> response = controller.posterPdf(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument out = Loader.loadPDF(firstPdfFromZip(drainBody(response)))) {
assertThat(out.getNumberOfPages()).isEqualTo(4);
}
}
@Test
void posterPdf_invalidPageSize_throws() throws Exception {
MockMultipartFile file = createRealPdf(1, 600f, 400f);
wireFactory(file);
PosterPdfRequest request = new PosterPdfRequest();
request.setFileInput(file);
request.setPageSize("Foo");
request.setXFactor(2);
request.setYFactor(2);
assertThatThrownBy(() -> controller.posterPdf(request))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,156 @@
package stirling.software.SPDF.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class ToSinglePageControllerTest {
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
@TempDir Path tempDir;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ToSinglePageController controller;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
private MockMultipartFile createRealPdf(int numPages, float width, float height)
throws IOException {
Path path = tempDir.resolve("input.pdf");
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < numPages; i++) {
doc.addPage(new PDPage(new PDRectangle(width, height)));
}
doc.save(path.toFile());
}
return new MockMultipartFile(
"fileInput",
"input.pdf",
MediaType.APPLICATION_PDF_VALUE,
Files.readAllBytes(path));
}
private void wireFactory(MockMultipartFile file) throws IOException {
when(pdfDocumentFactory.load(any(PDFFile.class)))
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
.thenAnswer(inv -> new PDDocument());
}
@Test
void singlePage_combinesIntoOneTallPage() throws Exception {
MockMultipartFile file = createRealPdf(3, 200f, 300f);
wireFactory(file);
PDFFile request = new PDFFile();
request.setFileInput(file);
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF);
byte[] body = drainBody(response);
assertThat(body).isNotEmpty();
try (PDDocument out = Loader.loadPDF(body)) {
assertThat(out.getNumberOfPages()).isEqualTo(1);
PDRectangle box = out.getPage(0).getMediaBox();
assertThat(box.getWidth()).isEqualTo(200f);
assertThat(box.getHeight()).isEqualTo(900f);
}
}
@Test
void singlePageInput_returnsOnePage() throws Exception {
MockMultipartFile file = createRealPdf(1, 612f, 792f);
wireFactory(file);
PDFFile request = new PDFFile();
request.setFileInput(file);
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
try (PDDocument out = Loader.loadPDF(drainBody(response))) {
assertThat(out.getNumberOfPages()).isEqualTo(1);
}
}
@Test
void filenameSuffixApplied() throws Exception {
MockMultipartFile file = createRealPdf(2, 100f, 100f);
wireFactory(file);
PDFFile request = new PDFFile();
request.setFileInput(file);
ResponseEntity<Resource> response = controller.pdfToSinglePage(request);
assertThat(response.getHeaders().getContentDisposition().getFilename())
.isEqualTo("input_singlePage.pdf");
}
@Test
void propagatesIoException() throws Exception {
MockMultipartFile file = createRealPdf(2, 100f, 100f);
when(pdfDocumentFactory.load(any(PDFFile.class))).thenThrow(new IOException("load failed"));
PDFFile request = new PDFFile();
request.setFileInput(file);
assertThatThrownBy(() -> controller.pdfToSinglePage(request))
.isInstanceOf(IOException.class);
}
}