mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b170f96a | ||
|
|
d93c2798e8 | ||
|
|
a3dd9a39ff |
@@ -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)",
|
||||
|
||||
+25
-118
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user