diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index 690014859..ff2ee732c 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -51,11 +51,11 @@
"filesSelected": "{{count}} files selected",
"files": {
"title": "Files",
- "placeholder": "Select a PDF file in the main view to get started",
"upload": "Upload",
"uploadFiles": "Upload Files",
"addFiles": "Add files",
- "selectFromWorkbench": "Select files from the workbench or "
+ "selectFromWorkbench": "Select files from the workbench or ",
+ "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
},
"noFavourites": "No favourites added",
"downloadComplete": "Download Complete",
@@ -644,11 +644,29 @@
"merge": {
"tags": "merge,Page operations,Back end,server side",
"title": "Merge",
- "header": "Merge multiple PDFs (2+)",
- "sortByName": "Sort by name",
- "sortByDate": "Sort by date",
- "removeCertSign": "Remove digital signature in the merged file?",
- "submit": "Merge"
+ "removeDigitalSignature": "Remove digital signature in the merged file?",
+ "generateTableOfContents": "Generate table of contents in the merged file?",
+ "removeDigitalSignature.tooltip": {
+ "title": "Remove Digital Signature",
+ "description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF."
+ },
+ "generateTableOfContents.tooltip": {
+ "title": "Generate Table of Contents",
+ "description": "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers."
+ },
+ "submit": "Merge",
+ "sortBy": {
+ "description": "Files will be merged in the order they're selected. Drag to reorder or sort below.",
+ "label": "Sort By",
+ "filename": "File Name",
+ "dateModified": "Date Modified",
+ "ascending": "Ascending",
+ "descending": "Descending",
+ "sort": "Sort"
+ },
+ "error": {
+ "failed": "An error occurred while merging the PDFs."
+ }
},
"split": {
"tags": "Page operations,divide,Multi Page,cut,server side",
diff --git a/frontend/src/components/tools/merge/MergeFileSorter.test.tsx b/frontend/src/components/tools/merge/MergeFileSorter.test.tsx
new file mode 100644
index 000000000..302777261
--- /dev/null
+++ b/frontend/src/components/tools/merge/MergeFileSorter.test.tsx
@@ -0,0 +1,182 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MantineProvider } from '@mantine/core';
+import MergeFileSorter from './MergeFileSorter';
+
+// Mock useTranslation with predictable return values
+const mockT = vi.fn((key: string) => `mock-${key}`);
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: mockT })
+}));
+
+// Wrapper component to provide Mantine context
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('MergeFileSorter', () => {
+ const mockOnSortFiles = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should render sort options dropdown, direction toggle, and sort button', () => {
+ render(
+
+
+
+ );
+
+ // Should have a select dropdown (Mantine Select uses textbox role)
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+
+ // Should have direction toggle button
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(2); // ActionIcon + Sort Button
+
+ // Should have sort button with text
+ expect(screen.getByText('mock-merge.sortBy.sort')).toBeInTheDocument();
+ });
+
+ test('should render description text', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument();
+ });
+
+ test('should have filename selected by default', () => {
+ render(
+
+
+
+ );
+
+ const select = screen.getByRole('textbox');
+ expect(select).toHaveValue('mock-merge.sortBy.filename');
+ });
+
+ test('should show ascending direction by default', () => {
+ render(
+
+
+
+ );
+
+ // Should show ascending arrow icon
+ const directionButton = screen.getAllByRole('button')[0];
+ expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
+ });
+
+ test('should toggle direction when direction button is clicked', () => {
+ render(
+
+
+
+ );
+
+ const directionButton = screen.getAllByRole('button')[0];
+
+ // Initially ascending
+ expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
+
+ // Click to toggle to descending
+ fireEvent.click(directionButton);
+ expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.descending');
+
+ // Click again to toggle back to ascending
+ fireEvent.click(directionButton);
+ expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
+ });
+
+ test('should call onSortFiles with correct parameters when sort button is clicked', () => {
+ render(
+
+
+
+ );
+
+ const sortButton = screen.getByText('mock-merge.sortBy.sort');
+ fireEvent.click(sortButton);
+
+ // Should be called with default values (filename, ascending)
+ expect(mockOnSortFiles).toHaveBeenCalledWith('filename', true);
+ });
+
+ test('should call onSortFiles with dateModified when dropdown is changed', () => {
+ render(
+
+
+
+ );
+
+ // Open the dropdown by clicking on the current selected value
+ const currentSelection = screen.getByText('mock-merge.sortBy.filename');
+ fireEvent.mouseDown(currentSelection);
+
+ // Click on the dateModified option
+ const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
+ fireEvent.click(dateModifiedOption);
+
+ const sortButton = screen.getByText('mock-merge.sortBy.sort');
+ fireEvent.click(sortButton);
+
+ expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
+ });
+
+ test('should call onSortFiles with descending direction when toggled', () => {
+ render(
+
+
+
+ );
+
+ const directionButton = screen.getAllByRole('button')[0];
+ const sortButton = screen.getByText('mock-merge.sortBy.sort');
+
+ // Toggle to descending
+ fireEvent.click(directionButton);
+
+ // Click sort
+ fireEvent.click(sortButton);
+
+ expect(mockOnSortFiles).toHaveBeenCalledWith('filename', false);
+ });
+
+ test('should handle complex user interaction sequence', () => {
+ render(
+
+
+
+ );
+
+ const directionButton = screen.getAllByRole('button')[0];
+ const sortButton = screen.getByText('mock-merge.sortBy.sort');
+
+ // 1. Change to dateModified
+ const currentSelection = screen.getByText('mock-merge.sortBy.filename');
+ fireEvent.mouseDown(currentSelection);
+ const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
+ fireEvent.click(dateModifiedOption);
+
+ // 2. Toggle to descending
+ fireEvent.click(directionButton);
+
+ // 3. Click sort
+ fireEvent.click(sortButton);
+
+ expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', false);
+
+ // 4. Toggle back to ascending
+ fireEvent.click(directionButton);
+
+ // 5. Sort again
+ fireEvent.click(sortButton);
+
+ expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
+ });
+});
diff --git a/frontend/src/components/tools/merge/MergeFileSorter.tsx b/frontend/src/components/tools/merge/MergeFileSorter.tsx
new file mode 100644
index 000000000..2b21afc64
--- /dev/null
+++ b/frontend/src/components/tools/merge/MergeFileSorter.tsx
@@ -0,0 +1,77 @@
+import React, { useState } from 'react';
+import { Group, Button, Text, ActionIcon, Stack, Select } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import SortIcon from '@mui/icons-material/Sort';
+import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
+import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
+
+interface MergeFileSorterProps {
+ onSortFiles: (sortType: 'filename' | 'dateModified', ascending: boolean) => void;
+ disabled?: boolean;
+}
+
+const MergeFileSorter: React.FC = ({
+ onSortFiles,
+ disabled = false,
+}) => {
+ const { t } = useTranslation();
+ const [sortType, setSortType] = useState<'filename' | 'dateModified'>('filename');
+ const [ascending, setAscending] = useState(true);
+
+ const sortOptions = [
+ { value: 'filename', label: t('merge.sortBy.filename', 'File Name') },
+ { value: 'dateModified', label: t('merge.sortBy.dateModified', 'Date Modified') },
+ ];
+
+ const handleSort = () => {
+ onSortFiles(sortType, ascending);
+ };
+
+ const handleDirectionToggle = () => {
+ setAscending(!ascending);
+ };
+
+ return (
+
+
+ {t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")}
+
+
+
+
+
+ }
+ onClick={handleSort}
+ disabled={disabled}
+ fullWidth
+ >
+ {t('merge.sortBy.sort', 'Sort')}
+
+
+
+ );
+};
+
+export default MergeFileSorter;
diff --git a/frontend/src/components/tools/merge/MergeSettings.test.tsx b/frontend/src/components/tools/merge/MergeSettings.test.tsx
new file mode 100644
index 000000000..7989fa6d0
--- /dev/null
+++ b/frontend/src/components/tools/merge/MergeSettings.test.tsx
@@ -0,0 +1,100 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MantineProvider } from '@mantine/core';
+import MergeSettings from './MergeSettings';
+import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
+
+// Mock useTranslation with predictable return values
+const mockT = vi.fn((key: string) => `mock-${key}`);
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: mockT })
+}));
+
+// Wrapper component to provide Mantine context
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('MergeSettings', () => {
+ const defaultParameters: MergeParameters = {
+ removeDigitalSignature: false,
+ generateTableOfContents: false,
+ };
+
+ const mockOnParameterChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should render both merge option checkboxes', () => {
+ render(
+
+
+
+ );
+
+ // Should render one checkbox for each parameter
+ const expectedCheckboxCount = Object.keys(defaultParameters).length;
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes).toHaveLength(expectedCheckboxCount);
+ });
+
+ test('should show correct initial checkbox states based on parameters', () => {
+ render(
+
+
+
+ );
+
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ // Both checkboxes should be unchecked initially
+ checkboxes.forEach(checkbox => {
+ expect(checkbox).not.toBeChecked();
+ });
+ });
+
+ test('should call onParameterChange with correct parameters when checkboxes are clicked', () => {
+ render(
+
+
+
+ );
+
+ const checkboxes = screen.getAllByRole('checkbox');
+
+ // Click the first checkbox (removeDigitalSignature - should toggle from false to true)
+ fireEvent.click(checkboxes[0]);
+ expect(mockOnParameterChange).toHaveBeenCalledWith('removeDigitalSignature', true);
+
+ // Click the second checkbox (generateTableOfContents - should toggle from false to true)
+ fireEvent.click(checkboxes[1]);
+ expect(mockOnParameterChange).toHaveBeenCalledWith('generateTableOfContents', true);
+ });
+
+ test('should call translation function with correct keys', () => {
+ render(
+
+
+
+ );
+
+ // Verify that translation keys are being called
+ expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?');
+ expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?');
+ });
+
+});
diff --git a/frontend/src/components/tools/merge/MergeSettings.tsx b/frontend/src/components/tools/merge/MergeSettings.tsx
new file mode 100644
index 000000000..a12695081
--- /dev/null
+++ b/frontend/src/components/tools/merge/MergeSettings.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { Stack, Checkbox } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
+
+interface MergeSettingsProps {
+ parameters: MergeParameters;
+ onParameterChange: (key: K, value: MergeParameters[K]) => void;
+ disabled?: boolean;
+}
+
+const MergeSettings: React.FC = ({
+ parameters,
+ onParameterChange,
+ disabled = false,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+ onParameterChange('removeDigitalSignature', event.currentTarget.checked)}
+ disabled={disabled}
+ />
+
+ onParameterChange('generateTableOfContents', event.currentTarget.checked)}
+ disabled={disabled}
+ />
+
+ );
+};
+
+export default MergeSettings;
diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx
index 354613ecb..b989e29b8 100644
--- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx
+++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx
@@ -10,11 +10,12 @@ import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: StirlingFile[];
- placeholder?: string;
+ minFiles?: number;
}
const FileStatusIndicator = ({
selectedFiles = [],
+ minFiles = 1,
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext();
@@ -55,6 +56,14 @@ const FileStatusIndicator = ({
return null;
}
+ const getPlaceholder = () => {
+ if (minFiles === undefined || minFiles === 1) {
+ return t("files.selectFromWorkbench", "Select files from the workbench or ");
+ } else {
+ return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", { count: minFiles });
+ }
+ };
+
// Check if there are no files in the workbench
if (stirlingFileStubs.length === 0) {
// If no recent files, show upload button
@@ -89,12 +98,12 @@ const FileStatusIndicator = ({
}
// Show selection status when there are files in workbench
- if (selectedFiles.length === 0) {
+ if (selectedFiles.length < minFiles) {
// If no recent files, show upload option
if (!hasRecentFiles) {
return (
- {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
+ {getPlaceholder() + " "}
- {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
+ {getPlaceholder() + " "}
openFilesModal()}
@@ -125,7 +134,7 @@ const FileStatusIndicator = ({
return (
- ✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
+ ✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
);
};
diff --git a/frontend/src/components/tools/shared/FilesToolStep.tsx b/frontend/src/components/tools/shared/FilesToolStep.tsx
index 8c188d4a9..5ce118bbe 100644
--- a/frontend/src/components/tools/shared/FilesToolStep.tsx
+++ b/frontend/src/components/tools/shared/FilesToolStep.tsx
@@ -7,7 +7,7 @@ export interface FilesToolStepProps {
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
- placeholder?: string;
+ minFiles?: number;
}
export function createFilesToolStep(
@@ -23,7 +23,7 @@ export function createFilesToolStep(
}, (
));
}
diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx
index 84051f426..9ea94bc4f 100644
--- a/frontend/src/components/tools/shared/createToolFlow.tsx
+++ b/frontend/src/components/tools/shared/createToolFlow.tsx
@@ -9,7 +9,7 @@ import { StirlingFile } from '../../../types/fileContext';
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
- placeholder?: string;
+ minFiles?: number;
onCollapsedClick?: () => void;
isVisible?: boolean;
}
@@ -76,7 +76,7 @@ export function createToolFlow(config: ToolFlowConfig) {
{config.files.isVisible !== false && steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
isCollapsed: config.files.isCollapsed,
- placeholder: config.files.placeholder,
+ minFiles: config.files.minFiles,
onCollapsedClick: config.files.onCollapsedClick
})}
diff --git a/frontend/src/components/tooltips/useMergeTips.tsx b/frontend/src/components/tooltips/useMergeTips.tsx
new file mode 100644
index 000000000..554c71540
--- /dev/null
+++ b/frontend/src/components/tooltips/useMergeTips.tsx
@@ -0,0 +1,19 @@
+import { useTranslation } from 'react-i18next';
+import { TooltipContent } from '../../types/tips';
+
+export const useMergeTips = (): TooltipContent => {
+ const { t } = useTranslation();
+
+ return {
+ tips: [
+ {
+ title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
+ description: t('merge.removeDigitalSignature.tooltip.description', 'Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF.')
+ },
+ {
+ title: t('merge.generateTableOfContents.tooltip.title', 'Generate Table of Contents'),
+ description: t('merge.generateTableOfContents.tooltip.description', 'Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers.')
+ }
+ ]
+ };
+};
diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts
index 4c4196764..b1a3988e6 100644
--- a/frontend/src/contexts/file/FileReducer.ts
+++ b/frontend/src/contexts/file/FileReducer.ts
@@ -145,12 +145,17 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
// Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
-
+ // Reorder selected files by passed order
+ const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
return {
...state,
files: {
...state.files,
ids: validIds
+ },
+ ui: {
+ ...state.ui,
+ selectedFileIds,
}
};
}
@@ -234,11 +239,14 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
case 'CONSUME_FILES': {
const { inputFileIds, outputStirlingFileStubs } = action.payload;
+
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
}
+
case 'UNDO_CONSUME_FILES': {
const { inputStirlingFileStubs, outputFileIds } = action.payload;
+
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
}
diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts
index 7d7f9b23e..056ed2aa0 100644
--- a/frontend/src/contexts/file/fileHooks.ts
+++ b/frontend/src/contexts/file/fileHooks.ts
@@ -136,13 +136,13 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
/**
* Hook for selected files (optimized for selection-based UI)
*/
-export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
+export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
- files: selectors.getSelectedFiles(),
- records: selectors.getSelectedStirlingFileStubs(),
- fileIds: state.ui.selectedFileIds
+ selectedFiles: selectors.getSelectedFiles(),
+ selectedRecords: selectors.getSelectedStirlingFileStubs(),
+ selectedFileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}
@@ -169,7 +169,6 @@ export function useFileContext() {
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
-
// File ID lookup
findFileId: (file: File) => {
return state.files.ids.find(id => {
diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx
index cf1ece3f1..c88d46fec 100644
--- a/frontend/src/data/useTranslatedToolRegistry.tsx
+++ b/frontend/src/data/useTranslatedToolRegistry.tsx
@@ -11,6 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions";
import RemovePassword from "../tools/RemovePassword";
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
import AddWatermark from "../tools/AddWatermark";
+import Merge from '../tools/Merge';
import Repair from "../tools/Repair";
import AutoRename from "../tools/AutoRename";
import SingleLargePage from "../tools/SingleLargePage";
@@ -30,6 +31,7 @@ import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperation";
import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
+import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
@@ -48,6 +50,7 @@ import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import Redact from "../tools/Redact";
import { ToolId } from "../types/toolId";
+import MergeSettings from '../components/tools/merge/MergeSettings';
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@@ -672,12 +675,14 @@ export function useFlatToolRegistry(): ToolRegistry {
mergePdfs: {
icon: ,
name: t("home.merge.title", "Merge"),
- component: null,
-
+ component: Merge,
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
+ endpoints: ["merge-pdfs"],
+ operationConfig: mergeOperationConfig,
+ settingsComponent: MergeSettings
},
"multi-tool": {
icon: ,
diff --git a/frontend/src/hooks/tools/merge/useMergeOperation.test.ts b/frontend/src/hooks/tools/merge/useMergeOperation.test.ts
new file mode 100644
index 000000000..f811febcf
--- /dev/null
+++ b/frontend/src/hooks/tools/merge/useMergeOperation.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useMergeOperation } from './useMergeOperation';
+import type { MergeParameters } from './useMergeParameters';
+
+// Mock the useToolOperation hook
+vi.mock('../shared/useToolOperation', async () => {
+ const actual = await vi.importActual('../shared/useToolOperation');
+ return {
+ ...actual,
+ useToolOperation: vi.fn()
+ };
+});
+
+// Mock the translation hook
+const mockT = vi.fn((key: string) => `translated-${key}`);
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: mockT })
+}));
+
+// Mock the error handler
+vi.mock('../../../utils/toolErrorHandler', () => ({
+ createStandardErrorHandler: vi.fn(() => 'error-handler-function')
+}));
+
+// Import the mocked function
+import { MultiFileToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
+
+describe('useMergeOperation', () => {
+ const mockUseToolOperation = vi.mocked(useToolOperation);
+
+ const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig;
+
+ const mockToolOperationReturn: ToolOperationHook = {
+ files: [],
+ thumbnails: [],
+ downloadUrl: null,
+ downloadFilename: '',
+ isLoading: false,
+ errorMessage: null,
+ status: '',
+ isGeneratingThumbnails: false,
+ progress: null,
+ executeOperation: vi.fn(),
+ resetResults: vi.fn(),
+ clearError: vi.fn(),
+ cancelOperation: vi.fn(),
+ undoOperation: function (): Promise {
+ throw new Error('Function not implemented.');
+ }
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
+ });
+
+ test('should build FormData correctly', () => {
+ renderHook(() => useMergeOperation());
+
+ const config = getToolConfig();
+ const mockFiles = [
+ new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
+ new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
+ ];
+ const parameters: MergeParameters = {
+ removeDigitalSignature: true,
+ generateTableOfContents: false
+ };
+
+ const formData = config.buildFormData(parameters, mockFiles);
+
+ // Verify files are appended
+ expect(formData.getAll('fileInput')).toHaveLength(2);
+ expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]);
+ expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]);
+
+ // Verify parameters are appended correctly
+ expect(formData.get('sortType')).toBe('orderProvided');
+ expect(formData.get('removeCertSign')).toBe('true');
+ expect(formData.get('generateToc')).toBe('false');
+ });
+
+ test('should handle response correctly', () => {
+ renderHook(() => useMergeOperation());
+
+ const config = getToolConfig();
+ const mockBlob = new Blob(['merged content'], { type: 'application/pdf' });
+ const mockFiles = [
+ new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
+ new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
+ ];
+
+ const result = config.responseHandler!(mockBlob, mockFiles) as File[];
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('merged_file1.pdf');
+ expect(result[0].type).toBe('application/pdf');
+ expect(result[0].size).toBe(mockBlob.size);
+ });
+
+ test('should return the hook result from useToolOperation', () => {
+ const { result } = renderHook(() => useMergeOperation());
+
+ expect(result.current).toBe(mockToolOperationReturn);
+ });
+
+ test('should use correct translation keys for error handling', () => {
+ renderHook(() => useMergeOperation());
+
+ expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.');
+ });
+
+ test('should build FormData with different parameter combinations', () => {
+ renderHook(() => useMergeOperation());
+
+ const config = getToolConfig();
+ const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })];
+
+ // Test case 1: All options disabled
+ const params1: MergeParameters = {
+ removeDigitalSignature: false,
+ generateTableOfContents: false
+ };
+ const formData1 = config.buildFormData(params1, mockFiles);
+ expect(formData1.get('removeCertSign')).toBe('false');
+ expect(formData1.get('generateToc')).toBe('false');
+
+ // Test case 2: All options enabled
+ const params2: MergeParameters = {
+ removeDigitalSignature: true,
+ generateTableOfContents: true
+ };
+ const formData2 = config.buildFormData(params2, mockFiles);
+ expect(formData2.get('removeCertSign')).toBe('true');
+ expect(formData2.get('generateToc')).toBe('true');
+ });
+});
diff --git a/frontend/src/hooks/tools/merge/useMergeOperation.ts b/frontend/src/hooks/tools/merge/useMergeOperation.ts
new file mode 100644
index 000000000..a356be443
--- /dev/null
+++ b/frontend/src/hooks/tools/merge/useMergeOperation.ts
@@ -0,0 +1,41 @@
+import { useTranslation } from 'react-i18next';
+import { useToolOperation, ResponseHandler, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
+import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
+import { MergeParameters } from './useMergeParameters';
+
+const buildFormData = (parameters: MergeParameters, files: File[]): FormData => {
+ const formData = new FormData();
+
+ files.forEach((file) => {
+ formData.append("fileInput", file);
+ });
+ formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
+ formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
+ formData.append("generateToc", parameters.generateTableOfContents.toString());
+
+ return formData;
+};
+
+const mergeResponseHandler: ResponseHandler = (blob: Blob, originalFiles: File[]): File[] => {
+ const filename = `merged_${originalFiles[0].name}`
+ return [new File([blob], filename, { type: 'application/pdf' })];
+};
+
+// Operation configuration for automation
+export const mergeOperationConfig: ToolOperationConfig = {
+ toolType: ToolType.multiFile,
+ buildFormData,
+ operationType: 'merge',
+ endpoint: '/api/v1/general/merge-pdfs',
+ filePrefix: 'merged_',
+ responseHandler: mergeResponseHandler,
+};
+
+export const useMergeOperation = () => {
+ const { t } = useTranslation();
+
+ return useToolOperation({
+ ...mergeOperationConfig,
+ getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
+ });
+};
diff --git a/frontend/src/hooks/tools/merge/useMergeParameters.test.ts b/frontend/src/hooks/tools/merge/useMergeParameters.test.ts
new file mode 100644
index 000000000..8294cdf6e
--- /dev/null
+++ b/frontend/src/hooks/tools/merge/useMergeParameters.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, test } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useMergeParameters, defaultParameters } from './useMergeParameters';
+
+describe('useMergeParameters', () => {
+ test('should initialize with default parameters', () => {
+ const { result } = renderHook(() => useMergeParameters());
+
+ expect(result.current.parameters).toStrictEqual(defaultParameters);
+ });
+
+ test.each([
+ { paramName: 'removeDigitalSignature' as const, value: true },
+ { paramName: 'removeDigitalSignature' as const, value: false },
+ { paramName: 'generateTableOfContents' as const, value: true },
+ { paramName: 'generateTableOfContents' as const, value: false }
+ ])('should update parameter $paramName to $value', ({ paramName, value }) => {
+ const { result } = renderHook(() => useMergeParameters());
+
+ act(() => {
+ result.current.updateParameter(paramName, value);
+ });
+
+ expect(result.current.parameters[paramName]).toBe(value);
+ });
+
+ test('should reset parameters to defaults', () => {
+ const { result } = renderHook(() => useMergeParameters());
+
+ // First, change some parameters
+ act(() => {
+ result.current.updateParameter('removeDigitalSignature', true);
+ result.current.updateParameter('generateTableOfContents', true);
+ });
+
+ expect(result.current.parameters.removeDigitalSignature).toBe(true);
+ expect(result.current.parameters.generateTableOfContents).toBe(true);
+
+ // Then reset
+ act(() => {
+ result.current.resetParameters();
+ });
+
+ expect(result.current.parameters).toStrictEqual(defaultParameters);
+ });
+
+ test('should validate parameters correctly - always returns true', () => {
+ const { result } = renderHook(() => useMergeParameters());
+
+ // Default state should be valid
+ expect(result.current.validateParameters()).toBe(true);
+
+ // Change parameters and validate again
+ act(() => {
+ result.current.updateParameter('removeDigitalSignature', true);
+ result.current.updateParameter('generateTableOfContents', true);
+ });
+
+ expect(result.current.validateParameters()).toBe(true);
+
+ // Reset and validate again
+ act(() => {
+ result.current.resetParameters();
+ });
+
+ expect(result.current.validateParameters()).toBe(true);
+ });
+});
diff --git a/frontend/src/hooks/tools/merge/useMergeParameters.ts b/frontend/src/hooks/tools/merge/useMergeParameters.ts
new file mode 100644
index 000000000..2abc416ca
--- /dev/null
+++ b/frontend/src/hooks/tools/merge/useMergeParameters.ts
@@ -0,0 +1,21 @@
+import { BaseParameters } from '../../../types/parameters';
+import { BaseParametersHook, useBaseParameters } from '../shared/useBaseParameters';
+
+export interface MergeParameters extends BaseParameters {
+ removeDigitalSignature: boolean;
+ generateTableOfContents: boolean;
+};
+
+export const defaultParameters: MergeParameters = {
+ removeDigitalSignature: false,
+ generateTableOfContents: false,
+};
+
+export type MergeParametersHook = BaseParametersHook;
+
+export const useMergeParameters = (): MergeParametersHook => {
+ return useBaseParameters({
+ defaultParameters,
+ endpointName: "merge-pdfs",
+ });
+};
diff --git a/frontend/src/hooks/tools/shared/useBaseTool.ts b/frontend/src/hooks/tools/shared/useBaseTool.ts
index 643e9bca5..723f95e44 100644
--- a/frontend/src/hooks/tools/shared/useBaseTool.ts
+++ b/frontend/src/hooks/tools/shared/useBaseTool.ts
@@ -38,7 +38,9 @@ export function useBaseTool(
useParams: () => BaseParametersHook,
useOperation: () => ToolOperationHook,
props: BaseToolProps,
+ options?: { minFiles?: number }
): BaseToolReturn {
+ const minFiles = options?.minFiles ?? 1;
const { onPreviewFile, onComplete, onError } = props;
// File selection
@@ -96,7 +98,7 @@ export function useBaseTool(
}, [operation, onPreviewFile]);
// Standard computed state
- const hasFiles = selectedFiles.length > 0;
+ const hasFiles = selectedFiles.length >= minFiles;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx
index db89db162..ba47df4f1 100644
--- a/frontend/src/tools/Automate.tsx
+++ b/frontend/src/tools/Automate.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useMemo } from "react";
+import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
@@ -161,25 +161,10 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
content
});
- // Dynamic file placeholder based on supported types
- const filesPlaceholder = useMemo(() => {
- if (currentStep === AUTOMATION_STEPS.RUN && stepData.automation?.operations?.length) {
- const firstOperation = stepData.automation.operations[0];
- const toolConfig = toolRegistry[firstOperation.operation as keyof typeof toolRegistry];
-
- // Check if the tool has supportedFormats that include non-PDF formats
- if (toolConfig?.supportedFormats && toolConfig.supportedFormats.length > 1) {
- return t('automate.files.placeholder.multiFormat', 'Select files to process (supports various formats)');
- }
- }
- return t('automate.files.placeholder', 'Select PDF files to process with this automation');
- }, [currentStep, stepData.automation, toolRegistry, t]);
-
// Always create files step to avoid conditional hook calls
const filesStep = createFilesToolStep(createStep, {
selectedFiles,
isCollapsed: hasResults,
- placeholder: filesPlaceholder
});
const automationSteps = [
diff --git a/frontend/src/tools/Convert.tsx b/frontend/src/tools/Convert.tsx
index 05fd87531..9019e6fc7 100644
--- a/frontend/src/tools/Convert.tsx
+++ b/frontend/src/tools/Convert.tsx
@@ -100,7 +100,6 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
files: {
selectedFiles,
isCollapsed: hasResults,
- placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"),
},
steps: [
{
diff --git a/frontend/src/tools/Flatten.tsx b/frontend/src/tools/Flatten.tsx
index 691a733f9..833757ecd 100644
--- a/frontend/src/tools/Flatten.tsx
+++ b/frontend/src/tools/Flatten.tsx
@@ -22,7 +22,6 @@ const Flatten = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
- placeholder: t("flatten.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [
{
@@ -59,4 +58,4 @@ const Flatten = (props: BaseToolProps) => {
// Static method to get the operation hook for automation
Flatten.tool = () => useFlattenOperation;
-export default Flatten as ToolComponent;
\ No newline at end of file
+export default Flatten as ToolComponent;
diff --git a/frontend/src/tools/Merge.tsx b/frontend/src/tools/Merge.tsx
new file mode 100644
index 000000000..c0ed04f1b
--- /dev/null
+++ b/frontend/src/tools/Merge.tsx
@@ -0,0 +1,98 @@
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import { createToolFlow } from "../components/tools/shared/createToolFlow";
+import MergeSettings from "../components/tools/merge/MergeSettings";
+import MergeFileSorter from "../components/tools/merge/MergeFileSorter";
+import { useMergeParameters } from "../hooks/tools/merge/useMergeParameters";
+import { useMergeOperation } from "../hooks/tools/merge/useMergeOperation";
+import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
+import { BaseToolProps, ToolComponent } from "../types/tool";
+import { useMergeTips } from "../components/tooltips/useMergeTips";
+import { useFileManagement, useSelectedFiles, useAllFiles } from "../contexts/FileContext";
+
+const Merge = (props: BaseToolProps) => {
+ const { t } = useTranslation();
+ const mergeTips = useMergeTips();
+
+ // File selection hooks for custom sorting
+ const { fileIds } = useAllFiles();
+ const { selectedRecords } = useSelectedFiles();
+ const { reorderFiles } = useFileManagement();
+
+ const base = useBaseTool(
+ 'merge',
+ useMergeParameters,
+ useMergeOperation,
+ props,
+ { minFiles: 2 }
+ );
+
+ // Custom file sorting logic for merge tool
+ const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => {
+ const sortedRecords = [...selectedRecords].sort((recordA, recordB) => {
+ let comparison = 0;
+ switch (sortType) {
+ case 'filename':
+ comparison = recordA.name.localeCompare(recordB.name);
+ break;
+ case 'dateModified':
+ comparison = recordA.lastModified - recordB.lastModified;
+ break;
+ }
+ return ascending ? comparison : -comparison;
+ });
+
+ const selectedIds = sortedRecords.map(record => record.id);
+ const deselectedIds = fileIds.filter(id => !selectedIds.includes(id));
+ reorderFiles([...selectedIds, ...deselectedIds]);
+ }, [selectedRecords, fileIds, reorderFiles]);
+
+ return createToolFlow({
+ files: {
+ selectedFiles: base.selectedFiles,
+ isCollapsed: base.hasResults,
+ minFiles: 2,
+ },
+ steps: [
+ {
+ title: "Sort Files",
+ isCollapsed: base.settingsCollapsed,
+ content: (
+
+ ),
+ },
+ {
+ title: "Settings",
+ isCollapsed: base.settingsCollapsed,
+ onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
+ tooltip: mergeTips,
+ content: (
+
+ ),
+ },
+ ],
+ executeButton: {
+ text: t("merge.submit", "Merge PDFs"),
+ isVisible: !base.hasResults,
+ loadingText: t("loading"),
+ onClick: base.handleExecute,
+ disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
+ },
+ review: {
+ isVisible: base.hasResults,
+ operation: base.operation,
+ title: t("merge.title", "Merge Results"),
+ onFileClick: base.handleThumbnailClick,
+ onUndo: base.handleUndo,
+ },
+ });
+};
+
+export default Merge as ToolComponent;
diff --git a/frontend/src/tools/RemoveCertificateSign.tsx b/frontend/src/tools/RemoveCertificateSign.tsx
index 195fc11f1..13a3068e6 100644
--- a/frontend/src/tools/RemoveCertificateSign.tsx
+++ b/frontend/src/tools/RemoveCertificateSign.tsx
@@ -19,7 +19,6 @@ const RemoveCertificateSign = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
- placeholder: t("removeCertSign.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {
diff --git a/frontend/src/tools/Repair.tsx b/frontend/src/tools/Repair.tsx
index c805592c5..88cf4eb28 100644
--- a/frontend/src/tools/Repair.tsx
+++ b/frontend/src/tools/Repair.tsx
@@ -19,7 +19,6 @@ const Repair = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
- placeholder: t("repair.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {
diff --git a/frontend/src/tools/Sanitize.tsx b/frontend/src/tools/Sanitize.tsx
index f37c8fa0b..9e0c46db3 100644
--- a/frontend/src/tools/Sanitize.tsx
+++ b/frontend/src/tools/Sanitize.tsx
@@ -20,7 +20,6 @@ const Sanitize = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
- placeholder: t("sanitize.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [
{
diff --git a/frontend/src/tools/SingleLargePage.tsx b/frontend/src/tools/SingleLargePage.tsx
index 095428e70..d31836feb 100644
--- a/frontend/src/tools/SingleLargePage.tsx
+++ b/frontend/src/tools/SingleLargePage.tsx
@@ -19,7 +19,6 @@ const SingleLargePage = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
- placeholder: t("pdfToSinglePage.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {
diff --git a/frontend/src/tools/UnlockPdfForms.tsx b/frontend/src/tools/UnlockPdfForms.tsx
index 332af5163..7dd4aeb18 100644
--- a/frontend/src/tools/UnlockPdfForms.tsx
+++ b/frontend/src/tools/UnlockPdfForms.tsx
@@ -19,7 +19,6 @@ const UnlockPdfForms = (props: BaseToolProps) => {
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasFiles || base.hasResults,
- placeholder: t("unlockPDFForms.files.placeholder", "Select a PDF file in the main view to get started"),
},
steps: [],
executeButton: {
diff --git a/testing/test_pdf_1.pdf b/testing/test_pdf_1.pdf
new file mode 100644
index 000000000..3d63420a2
--- /dev/null
+++ b/testing/test_pdf_1.pdf
@@ -0,0 +1,74 @@
+%PDF-1.3
+%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
+1 0 obj
+<<
+/F1 2 0 R /F2 3 0 R
+>>
+endobj
+2 0 obj
+<<
+/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
+>>
+endobj
+3 0 obj
+<<
+/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
+>>
+endobj
+4 0 obj
+<<
+/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
+/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
+>> /Rotate 0 /Trans <<
+
+>>
+ /Type /Page
+>>
+endobj
+5 0 obj
+<<
+/PageMode /UseNone /Pages 7 0 R /Type /Catalog
+>>
+endobj
+6 0 obj
+<<
+/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
+ /Subject (unspecified) /Title (untitled) /Trapped /False
+>>
+endobj
+7 0 obj
+<<
+/Count 1 /Kids [ 4 0 R ] /Type /Pages
+>>
+endobj
+8 0 obj
+<<
+/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
+>>
+stream
+GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp2V9^`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngc-TW~>endstream
+endobj
+xref
+0 9
+0000000000 65535 f
+0000000073 00000 n
+0000000114 00000 n
+0000000221 00000 n
+0000000333 00000 n
+0000000526 00000 n
+0000000594 00000 n
+0000000890 00000 n
+0000000949 00000 n
+trailer
+<<
+/ID
+[]
+% ReportLab generated PDF document -- digest (http://www.reportlab.com)
+
+/Info 6 0 R
+/Root 5 0 R
+/Size 9
+>>
+startxref
+1186
+%%EOF
diff --git a/testing/test_pdf_2.pdf b/testing/test_pdf_2.pdf
new file mode 100644
index 000000000..67f895ebf
--- /dev/null
+++ b/testing/test_pdf_2.pdf
@@ -0,0 +1,74 @@
+%PDF-1.3
+%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
+1 0 obj
+<<
+/F1 2 0 R /F2 3 0 R
+>>
+endobj
+2 0 obj
+<<
+/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
+>>
+endobj
+3 0 obj
+<<
+/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
+>>
+endobj
+4 0 obj
+<<
+/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
+/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
+>> /Rotate 0 /Trans <<
+
+>>
+ /Type /Page
+>>
+endobj
+5 0 obj
+<<
+/PageMode /UseNone /Pages 7 0 R /Type /Catalog
+>>
+endobj
+6 0 obj
+<<
+/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
+ /Subject (unspecified) /Title (untitled) /Trapped /False
+>>
+endobj
+7 0 obj
+<<
+/Count 1 /Kids [ 4 0 R ] /Type /Pages
+>>
+endobj
+8 0 obj
+<<
+/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
+>>
+stream
+GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3Iif`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngmo`i~>endstream
+endobj
+xref
+0 9
+0000000000 65535 f
+0000000073 00000 n
+0000000114 00000 n
+0000000221 00000 n
+0000000333 00000 n
+0000000526 00000 n
+0000000594 00000 n
+0000000890 00000 n
+0000000949 00000 n
+trailer
+<<
+/ID
+[<46f6a3460762da2956d1d3fc19ab996f><46f6a3460762da2956d1d3fc19ab996f>]
+% ReportLab generated PDF document -- digest (http://www.reportlab.com)
+
+/Info 6 0 R
+/Root 5 0 R
+/Size 9
+>>
+startxref
+1186
+%%EOF
diff --git a/testing/test_pdf_3.pdf b/testing/test_pdf_3.pdf
new file mode 100644
index 000000000..fb7e26930
--- /dev/null
+++ b/testing/test_pdf_3.pdf
@@ -0,0 +1,74 @@
+%PDF-1.3
+%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
+1 0 obj
+<<
+/F1 2 0 R /F2 3 0 R
+>>
+endobj
+2 0 obj
+<<
+/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
+>>
+endobj
+3 0 obj
+<<
+/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
+>>
+endobj
+4 0 obj
+<<
+/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
+/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
+>> /Rotate 0 /Trans <<
+
+>>
+ /Type /Page
+>>
+endobj
+5 0 obj
+<<
+/PageMode /UseNone /Pages 7 0 R /Type /Catalog
+>>
+endobj
+6 0 obj
+<<
+/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
+ /Subject (unspecified) /Title (untitled) /Trapped /False
+>>
+endobj
+7 0 obj
+<<
+/Count 1 /Kids [ 4 0 R ] /Type /Pages
+>>
+endobj
+8 0 obj
+<<
+/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
+>>
+stream
+GarW0YmS?5&4HDC`<2TCEOpM_A^cO6ZEVtG&1rQ7k5R.W5uPe>'T[Ma*9KfZqZs*-57""%'endstream
+endobj
+xref
+0 9
+0000000000 65535 f
+0000000073 00000 n
+0000000114 00000 n
+0000000221 00000 n
+0000000333 00000 n
+0000000526 00000 n
+0000000594 00000 n
+0000000890 00000 n
+0000000949 00000 n
+trailer
+<<
+/ID
+[<8c4eba11c30780ded30147f80c0aa46f><8c4eba11c30780ded30147f80c0aa46f>]
+% ReportLab generated PDF document -- digest (http://www.reportlab.com)
+
+/Info 6 0 R
+/Root 5 0 R
+/Size 9
+>>
+startxref
+1186
+%%EOF
diff --git a/testing/test_pdf_4.pdf b/testing/test_pdf_4.pdf
new file mode 100644
index 000000000..be7bc520d
--- /dev/null
+++ b/testing/test_pdf_4.pdf
@@ -0,0 +1,74 @@
+%PDF-1.3
+%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
+1 0 obj
+<<
+/F1 2 0 R /F2 3 0 R
+>>
+endobj
+2 0 obj
+<<
+/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
+>>
+endobj
+3 0 obj
+<<
+/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
+>>
+endobj
+4 0 obj
+<<
+/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
+/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
+>> /Rotate 0 /Trans <<
+
+>>
+ /Type /Page
+>>
+endobj
+5 0 obj
+<<
+/PageMode /UseNone /Pages 7 0 R /Type /Catalog
+>>
+endobj
+6 0 obj
+<<
+/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
+ /Subject (unspecified) /Title (untitled) /Trapped /False
+>>
+endobj
+7 0 obj
+<<
+/Count 1 /Kids [ 4 0 R ] /Type /Pages
+>>
+endobj
+8 0 obj
+<<
+/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
+>>
+stream
+GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3%Qb`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^nh.J$8~>endstream
+endobj
+xref
+0 9
+0000000000 65535 f
+0000000073 00000 n
+0000000114 00000 n
+0000000221 00000 n
+0000000333 00000 n
+0000000526 00000 n
+0000000594 00000 n
+0000000890 00000 n
+0000000949 00000 n
+trailer
+<<
+/ID
+[]
+% ReportLab generated PDF document -- digest (http://www.reportlab.com)
+
+/Info 6 0 R
+/Root 5 0 R
+/Size 9
+>>
+startxref
+1186
+%%EOF