Compare commits

..
Author SHA1 Message Date
Anthony Stirling eb2c18325b impl migration to pdfium for layout 2026-05-22 09:41:35 +01:00
12 changed files with 393 additions and 283 deletions
-70
View File
@@ -1,70 +0,0 @@
# Audit: feat/jpdfium-overlay
Verifies whether merging `feat/jpdfium-overlay` over `feat/jpdfium-integration` actually wins anything.
## What the branch actually does
Sole code commit: `fbadcda74 impl migration to pdfium for overlay and watermark and pagenumbers`. Net diff vs `feat/jpdfium-integration` is 5 files, +212 / -2.
For each of the three controllers the change is identical in shape:
```java
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox does ...
try (PdfDocument ignored = PdfDocument.open(pdfFile.getBytes())) {
} catch (Exception e) {
log.debug("JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
}
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
// ... all real overlay work done by PDFBox, exactly as before
}
```
There is no JPDFium overlay being performed. PDFBox still does 100% of:
- watermark tiled overlay (rows x cols, rotated, with non-Latin TTFs)
- 9-position page numbers with `{n}` / `{total}` / `{filename}` templating
- multi-line rotated stamps with rich `@var` templating
## Does the probe earn its keep?
No, on three counts:
1. **It does not short-circuit.** Catch swallows everything to DEBUG and falls through to PDFBox. A corrupt PDF still hits the full PDFBox parse path. The probe only adds work; it never saves any.
2. **It costs more than it saves on the hot path.** `PdfDocument.open(byte[])` does `MultipartFile.getBytes()` (full heap copy) + `JpdfiumLib.docOpenBytes` which `Arena.allocateFrom(JAVA_BYTE, data)` copies the entire payload into native memory, parses xref, then frees. For files > 10 MB `CustomPDFDocumentFactory.load(MultipartFile)` is normally stream-to-temp; the probe forces a full byte[] read in addition. Net: one extra heap-sized copy, one native copy, one PDFium parse, all discarded.
3. **No new corrupt-input test exercises the path.** `WatermarkControllerTest.OracleTests` and `PageNumbersControllerTest` only assert structure preservation on valid input. Probe is dead instrumentation as committed.
## JPDFium 1.0.x feature gaps that blocked real migration
Confirmed against `release/1.0.1` of JPDFium-pub.
| # | Gap | Where | Blocks |
|---|-----|-------|--------|
| 1 | `WatermarkApplier` is single-placement only; no row x col tiling. | `WatermarkApplier.applyTextWatermark` / `applyImageWatermark` | WatermarkController tiled overlay |
| 2 | High-level `WatermarkApplier` / `HeaderFooterApplier` only accept the built-in `FontName` enum. `PdfPageEditor.loadFont(byte[], type, cid)` exists but is not plumbed through. | `WatermarkApplier` line 72, `HeaderFooterApplier` line 103 | Non-Latin scripts (Arabic, JP, KR, ZH, Thai) |
| 3 | `HeaderFooterApplier.apply` hard-codes `size.width() / 2f` for both header and footer x. No 9-position grid. | `HeaderFooterApplier.apply` lines 49-56 | PageNumbersController 1..9 layout |
| 4 | `expandTemplate` only knows `{page}`, `{pages}`, `{date}`. | `HeaderFooterApplier.expandTemplate` line 93 | PageNumbers `{filename}`, Stamp `@filename`/`@author`/`@title`/`@uuid`/`@date{fmt}` etc. |
| 5 | `PdfAnnotationBuilder` takes a single `contents` string and a `Rect`; no multi-line layout, no per-line newline split, no rotated text-object matrices on the page content stream. | `PdfAnnotationBuilder.build` lines 130-137 | StampController `addTextStamp` multi-line + rotation |
| 6 | No image stamp/watermark with explicit width/height + rotation matrix; `WatermarkApplier.applyImageWatermark` forces `targetW = pageW * 0.3f` and never rotates. | `WatermarkApplier` lines 113-136 | Stamp/Watermark image variants |
Each maps to a one-line upstream feature request below.
## Upstream feature requests for JPDFium 1.0.2
1. `WatermarkApplier`: add `tileRows`/`tileCols` plus `widthSpacer`/`heightSpacer` builder fields and emit a row x col grid in `applyToPage`.
2. `Watermark`/`HeaderFooter` builders: accept `byte[] ttfFontData` (forward to `PdfPageEditor.loadFont` + use the returned `FPDF_FONT` in `createTextObject`).
3. `HeaderFooterApplier`: add `Position` enum (9-grid) for header and footer independently; replace the hard-coded `width/2f` x with `WatermarkApplier.computePosition`.
4. `HeaderFooter.expandTemplate`: add `{filename}`, `{author}`, `{title}`, `{subject}`, `{uuid}`, `{date:format}` and an `@var` alias set.
5. `PdfAnnotationBuilder` (or a new `TextStampApplier`): accept `List<String> lines`, `float rotationDegrees`, line-height, and emit one transformed text object per line via `PdfPageEditor.createTextObject` + `transform`.
6. `WatermarkApplier.applyImageWatermark`: take explicit `(width, height, rotation)` instead of fixed 30% width with no rotation.
## VERDICT
| Controller | Verdict | Reason |
|------------|---------|--------|
| WatermarkController | DROP | Probe is pure tax. Real migration blocked by gaps 1 + 2. |
| PageNumbersController | DROP | Probe is pure tax. Real migration blocked by gaps 3 + 4. |
| StampController | DROP | Probe is pure tax. Real migration blocked by gaps 4 + 5 + 6. |
## Bottom line
Drop the branch. The probe is a no-op that adds a full byte-copy + native parse to every overlay request and never short-circuits. Re-attempt overlay migration once JPDFium 1.0.2 lands feature requests 1-6, at which point a real migration (not a probe) is feasible.
@@ -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;
@@ -21,7 +21,6 @@ import io.github.pixee.security.Filenames;
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.SPDF.model.api.misc.AddPageNumbersRequest;
@@ -33,10 +32,8 @@ 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;
@MiscApi
@Slf4j
@RequiredArgsConstructor
public class PageNumbersController {
@@ -83,14 +80,6 @@ public class PageNumbersController {
}
}
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox runs.
// Holdout: JPDFium HeaderFooter only places top-center / bottom-center; PDFBox handles 9
// positions.
try (PdfDocument ignored = PdfDocument.open(file.getBytes())) {
} catch (Exception e) {
log.debug("JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
}
try (PDDocument document = pdfDocumentFactory.load(file)) {
float marginFactor =
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
@@ -42,7 +42,6 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.AddStampRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -55,10 +54,8 @@ import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
@MiscApi
@Slf4j
@RequiredArgsConstructor
public class StampController {
@@ -147,13 +144,7 @@ public class StampController {
default -> 0.035f;
};
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox renders the stamp.
// Holdout: PdfAnnotationBuilder lacks multi-line text + rotated text matrices used here.
try (PdfDocument ignored = PdfDocument.open(pdfFile.getBytes())) {
} catch (Exception e) {
log.debug("JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
}
// Load the input PDF
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
@@ -37,7 +37,6 @@ import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
@@ -50,10 +49,8 @@ import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.jpdfium.PdfDocument;
@SecurityApi
@Slf4j
@RequiredArgsConstructor
public class WatermarkController {
@@ -110,13 +107,7 @@ public class WatermarkController {
String customColor = request.getCustomColor();
boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage());
// JPDFium pre-validate catches corrupt PDFs cheaply before PDFBox does the tiled overlay.
// Holdout: JPDFium WatermarkApplier supports only single-position; PDFBox handles tiling.
try (PdfDocument ignored = PdfDocument.open(pdfFile.getBytes())) {
} catch (Exception e) {
log.debug("JPDFium pre-validate failed; proceeding with PDFBox: {}", e.getMessage());
}
// Load the input PDF with proper resource management
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
// Create a page in the document
@@ -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);
}
}
@@ -1,120 +0,0 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.*;
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.ByteArrayOutputStream;
import java.io.File;
import java.nio.file.Files;
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.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.io.Resource;
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.misc.AddPageNumbersRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@DisplayName("PageNumbersController Tests")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PageNumbersControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private PageNumbersController pageNumbersController;
private byte[] multiPagePdf;
@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;
});
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 4; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
multiPagePdf = baos.toByteArray();
}
}
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (java.io.InputStream in = response.getBody().getInputStream()) {
in.transferTo(baos);
}
return baos.toByteArray();
}
@Test
@DisplayName("Adds page numbers and preserves page count + MediaBox (oracle)")
void testAddPageNumbers_Oracle() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, multiPagePdf);
AddPageNumbersRequest request = new AddPageNumbersRequest();
request.setFileInput(pdfFile);
request.setCustomMargin("medium");
request.setPosition(8);
request.setStartingNumber(1);
request.setPagesToNumber("all");
request.setCustomText("{n}");
request.setFontSize(12);
request.setFontType("helvetica");
request.setFontColor("#000000");
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(multiPagePdf));
ResponseEntity<Resource> response = pageNumbersController.addPageNumbers(request);
byte[] outBytes = drainBody(response);
assertTrue(outBytes.length > 0, "output non-empty");
try (PDDocument out = Loader.loadPDF(outBytes)) {
assertEquals(4, out.getNumberOfPages(), "page count unchanged");
for (int i = 0; i < out.getNumberOfPages(); i++) {
PDPage p = out.getPage(i);
assertEquals(
PDRectangle.A4.getWidth(),
p.getMediaBox().getWidth(),
0.01f,
"MediaBox width preserved");
assertTrue(p.getContentStreams().hasNext(), "page has content streams");
}
}
}
}
@@ -485,65 +485,4 @@ class WatermarkControllerTest {
assertNotNull(response.getBody());
}
}
@Nested
@DisplayName("PDFBox Oracle Verification")
class OracleTests {
@Test
@DisplayName("Output preserves page count and MediaBox; content streams grow")
void testOutput_PreservesStructure() throws Exception {
byte[] multiPagePdf;
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 3; i++) {
doc.addPage(new PDPage(PDRectangle.A4));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
multiPagePdf = baos.toByteArray();
}
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput",
"oracle.pdf",
MediaType.APPLICATION_PDF_VALUE,
multiPagePdf);
AddWatermarkRequest request = new AddWatermarkRequest();
request.setFileInput(pdfFile);
request.setWatermarkType("text");
request.setWatermarkText("ORACLE");
request.setAlphabet("roman");
request.setFontSize(20);
request.setRotation(0);
request.setOpacity(0.5f);
request.setWidthSpacer(50);
request.setHeightSpacer(50);
request.setCustomColor("#000000");
request.setConvertPDFToImage(false);
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(multiPagePdf));
byte[] outBytes = drainBody(watermarkController.addWatermark(request));
try (PDDocument out = Loader.loadPDF(outBytes)) {
assertEquals(3, out.getNumberOfPages(), "page count unchanged");
for (int i = 0; i < out.getNumberOfPages(); i++) {
PDPage p = out.getPage(i);
assertEquals(
PDRectangle.A4.getWidth(),
p.getMediaBox().getWidth(),
0.01f,
"MediaBox width preserved");
assertEquals(
PDRectangle.A4.getHeight(),
p.getMediaBox().getHeight(),
0.01f,
"MediaBox height preserved");
assertTrue(p.getContentStreams().hasNext(), "page has content streams");
}
}
}
}
}