This commit is contained in:
Anthony Stirling
2026-05-15 12:42:57 +01:00
parent a3dd9a39ff
commit d93c2798e8
6 changed files with 64 additions and 8 deletions
@@ -287,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) {
@@ -312,11 +313,12 @@ public class MergeController {
try {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
// OPTIMIZE_RESOURCES_MODE closes source documents progressively and skips
// structure-tree copying. Trade-off: PDF/UA tags (used by screen readers) are not
// preserved in the merged output. Most users don't have tagged PDFs and this trades
// negligibly-different output for measurably lower peak heap during merge.
// 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(
PDFMergerUtility.DocumentMergeMode.OPTIMIZE_RESOURCES_MODE);
preserveAccessibility
? PDFMergerUtility.DocumentMergeMode.PDFBOX_LEGACY_MODE
: PDFMergerUtility.DocumentMergeMode.OPTIMIZE_RESOURCES_MODE);
long totalSize = 0;
File[] sourceFiles = new File[files.length];
for (int index = 0; index < files.length; index++) {
@@ -340,6 +342,21 @@ 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;
@@ -357,10 +374,6 @@ public class MergeController {
int[] pageCounts = generateToc ? collectPageCounts(sourceFiles) : null;
outputTempFile = new TempFile(tempFileManager, ".pdf");
// Hint the GC to reclaim merger transients before we open the merged document.
// The merger has just dropped its destination COSDocument; reclaiming that heap
// before loading the merged file again limits live-set during the modify pass.
System.gc();
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
@@ -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)",
@@ -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>
);
};
@@ -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>;