Compare commits

...
Author SHA1 Message Date
Anthony Stirling f3b170f96a i18n 2026-05-15 12:44:33 +01:00
Anthony Stirling d93c2798e8 fixes 2026-05-15 12:42:57 +01:00
Anthony Stirling a3dd9a39ff improve merge 2026-05-15 12:20:34 +01:00
9 changed files with 181 additions and 188 deletions
@@ -185,40 +185,39 @@ public class MergeController {
return new String[0];
}
// Adds a table of contents to the merged document using filenames as chapter titles
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
// Create the document outline
// Reads page counts from on-disk source files in read-only mode. A failed read falls back to
// 1 so TOC generation still produces a usable (if slightly misaligned) outline.
private int[] collectPageCounts(File[] sourceFiles) {
int[] counts = new int[sourceFiles.length];
for (int i = 0; i < sourceFiles.length; i++) {
try (PDDocument doc = pdfDocumentFactory.load(sourceFiles[i], true)) {
counts[i] = doc.getNumberOfPages();
} catch (IOException e) {
ExceptionUtils.logException("page count for TOC", e);
counts[i] = 1;
}
}
return counts;
}
// Adds a table of contents to the merged document using filenames as chapter titles.
// Page counts are passed in so we don't re-open every source PDF just to count pages.
private void addTableOfContents(
PDDocument mergedDocument, MultipartFile[] files, int[] pageCounts) {
PDDocumentOutline outline = new PDDocumentOutline();
mergedDocument.getDocumentCatalog().setDocumentOutline(outline);
int pageIndex = 0; // Current page index in the merged document
// Iterate through the original files
for (MultipartFile file : files) {
// Get the filename without extension to use as bookmark title
String filename = file.getOriginalFilename();
String title = GeneralUtils.removeExtension(filename);
// Create an outline item for this file
int pageIndex = 0;
for (int i = 0; i < files.length; i++) {
String title = GeneralUtils.removeExtension(files[i].getOriginalFilename());
PDOutlineItem item = new PDOutlineItem();
item.setTitle(title);
// Set the destination to the first page of this file in the merged document
if (pageIndex < mergedDocument.getNumberOfPages()) {
PDPage page = mergedDocument.getPage(pageIndex);
item.setDestination(page);
item.setDestination(mergedDocument.getPage(pageIndex));
}
// Add the item to the outline
outline.addLast(item);
// Increment page index for the next file
try (PDDocument doc = pdfDocumentFactory.load(file)) {
pageIndex += doc.getNumberOfPages();
} catch (IOException e) {
ExceptionUtils.logException("document loading for TOC generation", e);
pageIndex++; // Increment by at least one if we can't determine page count
}
int count = pageCounts[i];
pageIndex += count > 0 ? count : 1;
}
}
@@ -288,6 +287,7 @@ public class MergeController {
boolean removeCertSign = Boolean.TRUE.equals(request.getRemoveCertSign());
boolean generateToc = request.isGenerateToc();
boolean preserveAccessibility = request.isPreserveAccessibility();
MultipartFile[] files = request.getFileInput();
if (files == null) {
@@ -306,31 +306,34 @@ public class MergeController {
request.getSortType())); // Sort files based on requested sort type
}
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
// Hold the merge output until response streaming completes. We only close it on failure;
// on success ownership transfers to the response (deleted when Spring closes the stream).
TempFile mergeOutput = new TempFile(tempFileManager, ".pdf");
boolean keepMergeOutput = false;
try {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
// OPTIMIZE_RESOURCES_MODE closes source documents progressively and skips
// structure-tree copying — drops PDF/UA tags but uses much less heap.
// PDFBOX_LEGACY_MODE preserves tags at the cost of higher peak heap.
mergerUtility.setDocumentMergeMode(
preserveAccessibility
? PDFMergerUtility.DocumentMergeMode.PDFBOX_LEGACY_MODE
: PDFMergerUtility.DocumentMergeMode.OPTIMIZE_RESOURCES_MODE);
long totalSize = 0;
List<Integer> invalidIndexes = new ArrayList<>();
File[] sourceFiles = new File[files.length];
for (int index = 0; index < files.length; index++) {
MultipartFile multipartFile = files[index];
totalSize += multipartFile.getSize();
File tempFile =
tempFileManager.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
// Pre-validate each PDF so we can report which one(s) are broken
// Use the original MultipartFile to avoid deleting the tempFile during validation
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
// OK
} catch (IOException e) {
ExceptionUtils.logException("PDF pre-validate", e);
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
File tempFile = tempFileManager.convertMultipartFileToFile(multipartFile);
filesToDelete.add(tempFile);
sourceFiles[index] = tempFile;
mergerUtility.addSource(tempFile);
}
// Pre-validation is intentionally omitted: PDFMergerUtility surfaces corrupted inputs
// via PdfErrorUtils.isCorruptedPdfError below, and a separate validation pass would
// double-allocate PDDocument graphs and re-spool every source >10 MB to disk.
mergerUtility.setDestinationFileName(mt.getFile().getAbsolutePath());
mergerUtility.setDestinationFileName(mergeOutput.getFile().getAbsolutePath());
try {
mergerUtility.mergeDocuments(
@@ -339,39 +342,59 @@ public class MergeController {
} catch (IOException e) {
ExceptionUtils.logException("PDF merge", e);
if (PdfErrorUtils.isCorruptedPdfError(e)) {
// Identify which source file(s) are corrupt for operator diagnostics.
// Only runs on the failure path so the happy path stays fast.
List<String> badFiles = new ArrayList<>();
for (int i = 0; i < sourceFiles.length; i++) {
try (PDDocument ignored =
pdfDocumentFactory.load(sourceFiles[i], true)) {
// OK
} catch (IOException corruptInput) {
String name = files[i].getOriginalFilename();
badFiles.add(name != null ? name : ("index " + i));
}
}
if (!badFiles.isEmpty()) {
log.warn("Corrupted PDFs in merge input: {}", badFiles);
}
throw ExceptionUtils.createMultiplePdfCorruptedException(e);
}
throw e;
}
// Load the merged PDF document and operate on it inside try-with-resources
try (PDDocument mergedDocument = pdfDocumentFactory.load(mt.getFile())) {
// Remove signatures if removeCertSign is true
if (removeCertSign) {
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(PDSignatureField.class::isInstance)
.toList();
// Common case: caller wants neither cert-sign removal nor a TOC. Skip the
// load-and-resave round-trip entirely — the merged file on disk is the response.
// For 4000+ page jobs this avoids materialising the merged PDDocument in heap.
if (!removeCertSign && !generateToc) {
outputTempFile = mergeOutput;
keepMergeOutput = true;
} else {
// Page counts are needed only when generating a TOC. Read them from the already-
// on-disk source files in read-only mode (no metadata mutation, no extra spool).
int[] pageCounts = generateToc ? collectPageCounts(sourceFiles) : null;
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(
fieldsToRemove,
false); // Flatten the fields, effectively removing them
outputTempFile = new TempFile(tempFileManager, ".pdf");
try (PDDocument mergedDocument = pdfDocumentFactory.load(mergeOutput.getFile())) {
// Resource cache off for the modify pass — we never call getImage() here,
// and disabling it prevents PDFBox from caching XObjects when the page tree
// is iterated during outline insertion or AcroForm flattening.
mergedDocument.setResourceCache(null);
if (removeCertSign) {
PDDocumentCatalog catalog = mergedDocument.getDocumentCatalog();
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(PDSignatureField.class::isInstance)
.toList();
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(fieldsToRemove, false);
}
}
}
}
// Add table of contents if generateToc is true
if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files);
}
// Save the modified document to a temporary file
outputTempFile = new TempFile(tempFileManager, ".pdf");
try {
if (generateToc && files.length > 0) {
addTableOfContents(mergedDocument, files, pageCounts);
}
mergedDocument.save(outputTempFile.getFile());
} catch (Exception e) {
outputTempFile.close();
@@ -380,7 +403,7 @@ public class MergeController {
}
}
} catch (Exception ex) {
if (outputTempFile != null) {
if (outputTempFile != null && outputTempFile != mergeOutput) {
outputTempFile.close();
}
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
@@ -390,6 +413,9 @@ public class MergeController {
}
throw ex;
} finally {
if (!keepMergeOutput && outputTempFile != mergeOutput) {
mergeOutput.close();
}
for (File file : filesToDelete) {
tempFileManager.deleteTempFile(file); // Delete temporary files
}
@@ -40,6 +40,16 @@ public class MergePdfsRequest extends MultiplePDFFiles {
defaultValue = "false")
private boolean generateToc = false;
@Schema(
description =
"Flag indicating whether to preserve PDF/UA accessibility tags (structure"
+ " tree) in the merged output. When false (default) the merger runs in"
+ " resource-optimised mode which drops tags but uses significantly less"
+ " heap. Set true when merging tagged PDFs intended for screen readers.",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean preserveAccessibility = false;
@Schema(
description =
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
@@ -75,137 +75,57 @@ class MergeControllerTest {
void testAddTableOfContents_WithMultipleFiles_Success() throws Exception {
// Given
MultipartFile[] files = {mockFile1, mockFile2, mockFile3};
int[] pageCounts = {2, 2, 2};
// Mock the merged document setup
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(6);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
when(mockMergedDocument.getPage(2)).thenReturn(mockPage2);
when(mockMergedDocument.getPage(4)).thenReturn(mockPage1);
// Mock individual document loading for page count
PDDocument doc1 = mock(PDDocument.class);
PDDocument doc2 = mock(PDDocument.class);
PDDocument doc3 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(pdfDocumentFactory.load(mockFile2)).thenReturn(doc2);
when(pdfDocumentFactory.load(mockFile3)).thenReturn(doc3);
when(doc1.getNumberOfPages()).thenReturn(2);
when(doc2.getNumberOfPages()).thenReturn(2);
when(doc3.getNumberOfPages()).thenReturn(2);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
invokeAddToc(mockMergedDocument, files, pageCounts);
// Then
ArgumentCaptor<PDDocumentOutline> outlineCaptor =
ArgumentCaptor.forClass(PDDocumentOutline.class);
verify(mockCatalog).setDocumentOutline(outlineCaptor.capture());
assertNotNull(outlineCaptor.getValue());
PDDocumentOutline capturedOutline = outlineCaptor.getValue();
assertNotNull(capturedOutline);
// Verify that documents were loaded for page count
verify(pdfDocumentFactory).load(mockFile1);
verify(pdfDocumentFactory).load(mockFile2);
verify(pdfDocumentFactory).load(mockFile3);
// Verify document closing
verify(doc1).close();
verify(doc2).close();
verify(doc3).close();
// TOC must NOT re-open source PDFs to count pages — that was the OOM hot spot.
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_WithSingleFile_Success() throws Exception {
// Given
MultipartFile[] files = {mockFile1};
int[] pageCounts = {3};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(3);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(3);
invokeAddToc(mockMergedDocument, files, pageCounts);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(pdfDocumentFactory).load(mockFile1);
verify(doc1).close();
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_WithEmptyArray_Success() throws Exception {
// Given
MultipartFile[] files = {};
int[] pageCounts = {};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
invokeAddToc(mockMergedDocument, files, pageCounts);
// Then
verify(mockMergedDocument).getDocumentCatalog();
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_WithIOException_HandlesGracefully() throws Exception {
// Given
MultipartFile[] files = {mockFile1, mockFile2};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(4);
when(mockMergedDocument.getPage(anyInt()))
.thenReturn(mockPage1); // Use anyInt() to avoid stubbing conflicts
// First document loads successfully
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(2);
// Second document throws IOException
when(pdfDocumentFactory.load(mockFile2))
.thenThrow(new IOException("Failed to load document"));
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(
() -> addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files));
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(pdfDocumentFactory).load(mockFile1);
verify(pdfDocumentFactory).load(mockFile2);
verify(doc1).close();
}
@Test
void testAddTableOfContents_FilenameWithoutExtension_UsesFullName() throws Exception {
// Given
MockMultipartFile fileWithoutExtension =
new MockMultipartFile(
"file",
@@ -213,53 +133,40 @@ class MergeControllerTest {
MediaType.APPLICATION_PDF_VALUE,
"PDF content".getBytes());
MultipartFile[] files = {fileWithoutExtension};
int[] pageCounts = {1};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(1);
when(mockMergedDocument.getPage(0)).thenReturn(mockPage1);
PDDocument doc = mock(PDDocument.class);
when(pdfDocumentFactory.load(fileWithoutExtension)).thenReturn(doc);
when(doc.getNumberOfPages()).thenReturn(1);
invokeAddToc(mockMergedDocument, files, pageCounts);
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files);
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(doc).close();
verifyNoInteractions(pdfDocumentFactory);
}
@Test
void testAddTableOfContents_PageIndexExceedsDocumentPages_HandlesGracefully() throws Exception {
// Given
MultipartFile[] files = {mockFile1};
int[] pageCounts = {3};
when(mockMergedDocument.getDocumentCatalog()).thenReturn(mockCatalog);
when(mockMergedDocument.getNumberOfPages()).thenReturn(0); // No pages in merged document
PDDocument doc1 = mock(PDDocument.class);
when(pdfDocumentFactory.load(mockFile1)).thenReturn(doc1);
when(doc1.getNumberOfPages()).thenReturn(3);
assertDoesNotThrow(() -> invokeAddToc(mockMergedDocument, files, pageCounts));
// When
Method addTableOfContentsMethod =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class);
addTableOfContentsMethod.setAccessible(true);
// Should not throw exception
assertDoesNotThrow(
() -> addTableOfContentsMethod.invoke(mergeController, mockMergedDocument, files));
// Then
verify(mockCatalog).setDocumentOutline(any(PDDocumentOutline.class));
verify(mockMergedDocument, never()).getPage(anyInt());
verify(doc1).close();
verifyNoInteractions(pdfDocumentFactory);
}
private void invokeAddToc(PDDocument merged, MultipartFile[] files, int[] pageCounts)
throws Exception {
Method m =
MergeController.class.getDeclaredMethod(
"addTableOfContents", PDDocument.class, MultipartFile[].class, int[].class);
m.setAccessible(true);
m.invoke(mergeController, merged, files, pageCounts);
}
@Test
@@ -4600,6 +4600,13 @@ label = "Generate table of contents in the merged file?"
description = "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers."
title = "Generate Table of Contents"
[merge.preserveAccessibility]
label = "Preserve accessibility tags? (uses more memory on large merges)"
[merge.preserveAccessibility.tooltip]
description = "Keeps PDF/UA structure tags (used by screen readers) in the merged output. Increases peak memory usage; leave off for large merges if accessibility is not required."
title = "Preserve Accessibility Tags"
[merge.removeDigitalSignature]
label = "Remove digital signature in the merged file?"
@@ -19,6 +19,7 @@ describe("MergeSettings", () => {
const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
preserveAccessibility: false,
};
const mockOnParameterChange = vi.fn();
@@ -86,6 +87,13 @@ describe("MergeSettings", () => {
"generateTableOfContents",
true,
);
// Click the third checkbox (preserveAccessibility - should toggle from false to true)
fireEvent.click(checkboxes[2]);
expect(mockOnParameterChange).toHaveBeenCalledWith(
"preserveAccessibility",
true,
);
});
test("should call translation function with correct keys", () => {
@@ -107,5 +115,9 @@ describe("MergeSettings", () => {
"merge.generateTableOfContents.label",
"Generate table of contents in the merged file?",
);
expect(mockT).toHaveBeenCalledWith(
"merge.preserveAccessibility.label",
"Preserve accessibility tags? (uses more memory on large merges)",
);
});
});
@@ -50,6 +50,21 @@ const MergeSettings: React.FC<MergeSettingsProps> = ({
}
disabled={disabled}
/>
<Checkbox
label={t(
"merge.preserveAccessibility.label",
"Preserve accessibility tags? (uses more memory on large merges)",
)}
checked={parameters.preserveAccessibility}
onChange={(event) =>
onParameterChange(
"preserveAccessibility",
event.currentTarget.checked,
)
}
disabled={disabled}
/>
</Stack>
);
};
@@ -29,6 +29,16 @@ export const useMergeTips = (): TooltipContent => {
"Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers.",
),
},
{
title: t(
"merge.preserveAccessibility.tooltip.title",
"Preserve Accessibility Tags",
),
description: t(
"merge.preserveAccessibility.tooltip.description",
"Keeps PDF/UA structure tags (used by screen readers) in the merged output. Increases peak memory usage; leave off for large merges if accessibility is not required.",
),
},
],
};
};
@@ -33,6 +33,10 @@ const buildFormData = (
"generateToc",
(parameters.generateTableOfContents ?? false).toString(),
);
formData.append(
"preserveAccessibility",
(parameters.preserveAccessibility ?? false).toString(),
);
return formData;
};
@@ -7,11 +7,13 @@ import {
export interface MergeParameters extends BaseParameters {
removeDigitalSignature: boolean;
generateTableOfContents: boolean;
preserveAccessibility: boolean;
}
export const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
preserveAccessibility: false,
};
export type MergeParametersHook = BaseParametersHook<MergeParameters>;