diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index c72f553e73..804eff28cb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -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 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 diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java index 2851f018f6..d0fd936760 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java @@ -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)", diff --git a/frontend/src/core/components/tools/merge/MergeSettings.test.tsx b/frontend/src/core/components/tools/merge/MergeSettings.test.tsx index 2249d64c45..fc5345b0e4 100644 --- a/frontend/src/core/components/tools/merge/MergeSettings.test.tsx +++ b/frontend/src/core/components/tools/merge/MergeSettings.test.tsx @@ -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)", + ); }); }); diff --git a/frontend/src/core/components/tools/merge/MergeSettings.tsx b/frontend/src/core/components/tools/merge/MergeSettings.tsx index 9d91682814..01b77cae61 100644 --- a/frontend/src/core/components/tools/merge/MergeSettings.tsx +++ b/frontend/src/core/components/tools/merge/MergeSettings.tsx @@ -50,6 +50,21 @@ const MergeSettings: React.FC = ({ } disabled={disabled} /> + + + onParameterChange( + "preserveAccessibility", + event.currentTarget.checked, + ) + } + disabled={disabled} + /> ); }; diff --git a/frontend/src/core/hooks/tools/merge/useMergeOperation.ts b/frontend/src/core/hooks/tools/merge/useMergeOperation.ts index f4e163a56d..b98ec8d665 100644 --- a/frontend/src/core/hooks/tools/merge/useMergeOperation.ts +++ b/frontend/src/core/hooks/tools/merge/useMergeOperation.ts @@ -33,6 +33,10 @@ const buildFormData = ( "generateToc", (parameters.generateTableOfContents ?? false).toString(), ); + formData.append( + "preserveAccessibility", + (parameters.preserveAccessibility ?? false).toString(), + ); return formData; }; diff --git a/frontend/src/core/hooks/tools/merge/useMergeParameters.ts b/frontend/src/core/hooks/tools/merge/useMergeParameters.ts index 4bfa3534cf..7dc609d262 100644 --- a/frontend/src/core/hooks/tools/merge/useMergeParameters.ts +++ b/frontend/src/core/hooks/tools/merge/useMergeParameters.ts @@ -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;