Compare commits

..

No commits in common. "5d7fb638afcfae54494cf555bd3b4dcfda023e0c" and "1709ca904925e2c6f672fcbeeabe806eb48d79f6" have entirely different histories.

79 changed files with 950 additions and 3713 deletions

View File

@ -52,18 +52,16 @@
},
"scripts": {
"predev": "npm run generate-icons",
"dev": "npm run typecheck && vite",
"dev": "npx tsc --noEmit && vite",
"prebuild": "npm run generate-icons",
"lint": "eslint",
"build": "npm run typecheck && vite build",
"lint": "npx eslint",
"build": "npx tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck && npm run lint && npm run test:run",
"generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js",
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
"test": "vitest",
"test:run": "vitest run",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",

View File

@ -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 ",
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
"selectFromWorkbench": "Select files from the workbench or "
},
"noFavourites": "No favourites added",
"downloadComplete": "Download Complete",
@ -498,9 +498,13 @@
"title": "Show Javascript",
"desc": "Searches and displays any JS injected into a PDF"
},
"autoRedact": {
"title": "Auto Redact",
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
},
"redact": {
"title": "Redact",
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
"title": "Manual Redaction",
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"overlay-pdfs": {
"title": "Overlay PDFs",
@ -644,29 +648,11 @@
"merge": {
"tags": "merge,Page operations,Back end,server side",
"title": "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."
}
"header": "Merge multiple PDFs (2+)",
"sortByName": "Sort by name",
"sortByDate": "Sort by date",
"removeCertSign": "Remove digital signature in the merged file?",
"submit": "Merge"
},
"split": {
"tags": "Page operations,divide,Multi Page,cut,server side",
@ -1468,6 +1454,7 @@
"submit": "Submit"
},
"scalePages": {
"tags": "resize,modify,dimension,adapt",
"title": "Adjust page-scale",
"header": "Adjust page-scale",
"pageSize": "Size of a page of the document.",
@ -1475,44 +1462,6 @@
"scaleFactor": "Zoom level (crop) of a page.",
"submit": "Submit"
},
"adjustPageScale": {
"tags": "resize,modify,dimension,adapt",
"title": "Adjust Page Scale",
"header": "Adjust Page Scale",
"scaleFactor": {
"label": "Scale Factor"
},
"pageSize": {
"label": "Target Page Size",
"keep": "Keep Original Size",
"letter": "Letter",
"legal": "Legal"
},
"submit": "Adjust Page Scale",
"error": {
"failed": "An error occurred while adjusting the page scale."
},
"tooltip": {
"header": {
"title": "Page Scale Settings Overview"
},
"description": {
"title": "Description",
"text": "Adjust the size of PDF content and change the page dimensions."
},
"scaleFactor": {
"title": "Scale Factor",
"text": "Controls how large or small the content appears on the page. Content is scaled and centred - if scaled content is larger than the page size, it may be cropped.",
"bullet1": "1.0 = Original size",
"bullet2": "0.5 = Half size (50% smaller)",
"bullet3": "2.0 = Double size (200% larger, may crop)"
},
"pageSize": {
"title": "Target Page Size",
"text": "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, whilst other options resize to standard paper sizes."
}
}
},
"add-page-numbers": {
"tags": "paginate,label,organize,index"
},
@ -1634,123 +1583,50 @@
"downloadJS": "Download Javascript",
"submit": "Show"
},
"autoRedact": {
"tags": "Redact,Hide,black out,black,marker,hidden",
"title": "Auto Redact",
"header": "Auto Redact",
"colorLabel": "Colour",
"textsToRedactLabel": "Text to Redact (line-separated)",
"textsToRedactPlaceholder": "e.g. \\nConfidential \\nTop-Secret",
"useRegexLabel": "Use Regex",
"wholeWordSearchLabel": "Whole Word Search",
"customPaddingLabel": "Custom Extra Padding",
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
"submitButton": "Submit"
},
"redact": {
"tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact",
"title": "Redact",
"tags": "Redact,Hide,black out,black,marker,hidden,manual",
"title": "Manual Redaction",
"header": "Manual Redaction",
"submit": "Redact",
"error": {
"failed": "An error occurred while redacting the PDF."
"textBasedRedaction": "Text based Redaction",
"pageBasedRedaction": "Page-based Redaction",
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
"pageRedactionNumbers": {
"title": "Pages",
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
},
"modeSelector": {
"title": "Redaction Method",
"mode": "Mode",
"automatic": "Automatic",
"automaticDesc": "Redact text based on search terms",
"manual": "Manual",
"manualDesc": "Click and drag to redact specific areas",
"manualComingSoon": "Manual redaction coming soon"
"redactionColor": {
"title": "Redaction Color"
},
"auto": {
"header": "Auto Redact",
"settings": {
"title": "Redaction Settings",
"advancedTitle": "Advanced"
},
"colorLabel": "Box Colour",
"wordsToRedact": {
"title": "Words to Redact",
"placeholder": "Enter a word",
"add": "Add",
"examples": "Examples: Confidential, Top-Secret"
},
"useRegexLabel": "Use Regex",
"wholeWordSearchLabel": "Whole Word Search",
"customPaddingLabel": "Custom Extra Padding",
"convertPDFToImageLabel": "Convert PDF to PDF-Image"
},
"tooltip": {
"mode": {
"header": {
"title": "Redaction Method"
},
"automatic": {
"title": "Automatic Redaction",
"text": "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, addresses, or confidential markers."
},
"manual": {
"title": "Manual Redaction",
"text": "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)"
}
},
"words": {
"header": {
"title": "Words to Redact"
},
"description": {
"title": "Text Matching",
"text": "Enter words or phrases to find and redact in your document. Each word will be searched for separately."
},
"bullet1": "Add one word at a time",
"bullet2": "Press Enter or click 'Add Another' to add",
"bullet3": "Click × to remove words",
"examples": {
"title": "Common Examples",
"text": "Typical words to redact include: bank details, email addresses, or specific names."
}
},
"advanced": {
"header": {
"title": "Advanced Redaction Settings"
},
"color": {
"title": "Box Colour & Padding",
"text": "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."
},
"regex": {
"title": "Use Regex",
"text": "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns.",
"bullet1": "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format",
"bullet2": "Use with caution - test thoroughly"
},
"wholeWord": {
"title": "Whole Word Search",
"text": "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled."
},
"convert": {
"title": "Convert to PDF-Image",
"text": "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable."
}
}
},
"manual": {
"header": "Manual Redaction",
"textBasedRedaction": "Text-based Redaction",
"pageBasedRedaction": "Page-based Redaction",
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
"pageRedactionNumbers": {
"title": "Pages",
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
},
"redactionColor": {
"title": "Redaction Colour"
},
"export": "Export",
"upload": "Upload",
"boxRedaction": "Box draw redaction",
"zoom": "Zoom",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"nextPage": "Next Page",
"previousPage": "Previous Page",
"toggleSidebar": "Toggle Sidebar",
"showThumbnails": "Show Thumbnails",
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
"showAttachments": "Show Attachments",
"showLayers": "Show Layers (double-click to reset all layers to the default state)",
"colourPicker": "Colour Picker",
"findCurrentOutlineItem": "Find current outline item",
"applyChanges": "Apply Changes"
}
"export": "Export",
"upload": "Upload",
"boxRedaction": "Box draw redaction",
"zoom": "Zoom",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"nextPage": "Next Page",
"previousPage": "Previous Page",
"toggleSidebar": "Toggle Sidebar",
"showThumbnails": "Show Thumbnails",
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
"showAttatchments": "Show Attachments",
"showLayers": "Show Layers (double-click to reset all layers to the default state)",
"colourPicker": "Colour Picker",
"findCurrentOutlineItem": "Find current outline item",
"applyChanges": "Apply Changes"
},
"tableExtraxt": {
"tags": "CSV,Table Extraction,extract,convert"
@ -1961,11 +1837,6 @@
"title": "Compress",
"desc": "Compress PDFs to reduce their file size.",
"header": "Compress PDF",
"method": {
"title": "Compression Method",
"quality": "Quality",
"filesize": "File Size"
},
"credit": "This service uses qpdf for PDF Compress/Optimisation.",
"grayscale": {
"label": "Apply Grayscale for Compression"

View File

@ -385,13 +385,6 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@posthog/core",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tailwindcss/node",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss",
@ -749,13 +742,6 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "core-js",
"moduleUrl": "https://github.com/zloirock/core-js",
"moduleVersion": "3.45.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "core-util-is",
"moduleUrl": "https://github.com/isaacs/core-util-is",
@ -938,13 +924,6 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "fflate",
"moduleUrl": "https://github.com/101arrowz/fflate",
"moduleVersion": "0.4.8",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "file-selector",
"moduleUrl": "https://github.com/react-dropzone/file-selector",
@ -1554,20 +1533,6 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "posthog-js",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.261.0",
"moduleLicense": "MIT*",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "preact",
"moduleUrl": "https://github.com/preactjs/preact",
"moduleVersion": "10.27.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "pretty-format",
"moduleUrl": "https://github.com/facebook/jest",
@ -1963,7 +1928,7 @@
{
"moduleName": "typescript",
"moduleUrl": "https://github.com/microsoft/TypeScript",
"moduleVersion": "5.9.2",
"moduleVersion": "5.8.3",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@ -2030,13 +1995,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "web-vitals",
"moduleUrl": "https://github.com/GoogleChrome/web-vitals",
"moduleVersion": "4.2.4",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "webidl-conversions",
"moduleUrl": "https://github.com/jsdom/webidl-conversions",

View File

@ -1,216 +0,0 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import ButtonSelector from './ButtonSelector';
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('ButtonSelector', () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all options as buttons', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Test Label"
/>
</TestWrapper>
);
expect(screen.getByText('Test Label')).toBeInTheDocument();
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
});
test('should highlight selected button with filled variant', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
label="Selection Label"
/>
</TestWrapper>
);
const selectedButton = screen.getByRole('button', { name: 'Option 1' });
const unselectedButton = screen.getByRole('button', { name: 'Option 2' });
// Check data-variant attribute for filled/outline
expect(selectedButton).toHaveAttribute('data-variant', 'filled');
expect(unselectedButton).toHaveAttribute('data-variant', 'outline');
expect(screen.getByText('Selection Label')).toBeInTheDocument();
});
test('should call onChange when button is clicked', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
expect(mockOnChange).toHaveBeenCalledWith('option2');
});
test('should handle undefined value (no selection)', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value={undefined}
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
// Both buttons should be outlined when no value is selected
const button1 = screen.getByRole('button', { name: 'Option 1' });
const button2 = screen.getByRole('button', { name: 'Option 2' });
expect(button1).toHaveAttribute('data-variant', 'outline');
expect(button2).toHaveAttribute('data-variant', 'outline');
});
test.each([
{
description: 'disable buttons when disabled prop is true',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
globalDisabled: true,
expectedStates: [true, true],
},
{
description: 'disable individual options when option.disabled is true',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
],
globalDisabled: false,
expectedStates: [false, true],
},
])('should $description', ({ options, globalDisabled, expectedStates }) => {
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
disabled={globalDisabled}
/>
</TestWrapper>
);
options.forEach((option, index) => {
const button = screen.getByRole('button', { name: option.label });
expect(button).toHaveProperty('disabled', expectedStates[index]);
});
});
test('should not call onChange when disabled button is clicked', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2', disabled: true },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
expect(mockOnChange).not.toHaveBeenCalled();
});
test('should not apply fullWidth styling when fullWidth is false', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
fullWidth={false}
label="Layout Label"
/>
</TestWrapper>
);
const button = screen.getByRole('button', { name: 'Option 1' });
expect(button).not.toHaveStyle({ flex: '1' });
expect(screen.getByText('Layout Label')).toBeInTheDocument();
});
test('should not render label element when not provided', () => {
const options = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
];
const { container } = render(
<TestWrapper>
<ButtonSelector
value="option1"
onChange={mockOnChange}
options={options}
/>
</TestWrapper>
);
// Should render buttons
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
// Stack should only contain the Group (buttons), no Text element for label
const stackElement = container.querySelector('[class*="mantine-Stack-root"]');
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
});
});

View File

@ -1,59 +0,0 @@
import { Button, Group, Stack, Text } from "@mantine/core";
export interface ButtonOption<T> {
value: T;
label: string;
disabled?: boolean;
}
interface ButtonSelectorProps<T> {
value: T | undefined;
onChange: (value: T) => void;
options: ButtonOption<T>[];
label?: string;
disabled?: boolean;
fullWidth?: boolean;
}
const ButtonSelector = <T extends string>({
value,
onChange,
options,
label = undefined,
disabled = false,
fullWidth = true,
}: ButtonSelectorProps<T>) => {
return (
<Stack gap='var(--mantine-spacing-sm)'>
{/* Label (if it exists) */}
{label && <Text style={{
fontSize: "var(--mantine-font-size-sm)",
lineHeight: "var(--mantine-line-height-sm)",
fontWeight: "var(--font-weight-medium)",
}}>{label}</Text>}
{/* Buttons */}
<Group gap='4px'>
{options.map((option) => (
<Button
key={option.value}
variant={value === option.value ? 'filled' : 'outline'}
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
onClick={() => onChange(option.value)}
disabled={disabled || option.disabled}
style={{
flex: fullWidth ? 1 : undefined,
height: 'auto',
minHeight: '2.5rem',
fontSize: 'var(--mantine-font-size-sm)'
}}
>
{option.label}
</Button>
))}
</Group>
</Stack>
);
};
export default ButtonSelector;

View File

@ -4,7 +4,7 @@ import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPa
interface AddPasswordSettingsProps {
parameters: AddPasswordParameters;
onParameterChange: <K extends keyof AddPasswordParameters>(key: K, value: AddPasswordParameters[K]) => void;
onParameterChange: (key: keyof AddPasswordParameters, value: any) => void;
disabled?: boolean;
}

View File

@ -1,5 +1,5 @@
import { Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ButtonSelector from "../../shared/ButtonSelector";
interface WatermarkTypeSettingsProps {
watermarkType?: 'text' | 'image';
@ -11,21 +11,32 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
const { t } = useTranslation();
return (
<ButtonSelector
value={watermarkType}
onChange={onWatermarkTypeChange}
options={[
{
value: 'text',
label: t('watermark.watermarkType.text', 'Text'),
},
{
value: 'image',
label: t('watermark.watermarkType.image', 'Image'),
},
]}
disabled={disabled}
/>
<Stack gap="sm">
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={watermarkType === 'text' ? 'filled' : 'outline'}
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('text')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('watermark.watermarkType.text', 'Text')}
</div>
</Button>
<Button
variant={watermarkType === 'image' ? 'filled' : 'outline'}
color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('image')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('watermark.watermarkType.image', 'Image')}
</div>
</Button>
</div>
</Stack>
);
};

View File

@ -1,64 +0,0 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import AdjustPageScaleSettings from './AdjustPageScaleSettings';
import { AdjustPageScaleParameters, PageSize } from '../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
// Mock useTranslation with predictable return values
const mockT = vi.fn((key: string, fallback?: string) => fallback || `mock-${key}`);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('AdjustPageScaleSettings', () => {
const defaultParameters: AdjustPageScaleParameters = {
scaleFactor: 1.0,
pageSize: PageSize.KEEP,
};
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render without crashing', () => {
render(
<TestWrapper>
<AdjustPageScaleSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Basic render test - component renders without throwing
expect(screen.getByText('Scale Factor')).toBeInTheDocument();
expect(screen.getByText('Target Page Size')).toBeInTheDocument();
});
test('should render with custom parameters', () => {
const customParameters: AdjustPageScaleParameters = {
scaleFactor: 2.5,
pageSize: PageSize.A4,
};
render(
<TestWrapper>
<AdjustPageScaleSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Component renders successfully with custom parameters
expect(screen.getByText('Scale Factor')).toBeInTheDocument();
expect(screen.getByText('Target Page Size')).toBeInTheDocument();
});
});

View File

@ -1,55 +0,0 @@
import { Stack, NumberInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AdjustPageScaleParameters, PageSize } from "../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
interface AdjustPageScaleSettingsProps {
parameters: AdjustPageScaleParameters;
onParameterChange: <K extends keyof AdjustPageScaleParameters>(key: K, value: AdjustPageScaleParameters[K]) => void;
disabled?: boolean;
}
const AdjustPageScaleSettings = ({ parameters, onParameterChange, disabled = false }: AdjustPageScaleSettingsProps) => {
const { t } = useTranslation();
const pageSizeOptions = [
{ value: PageSize.KEEP, label: t('adjustPageScale.pageSize.keep', 'Keep Original Size') },
{ value: PageSize.A0, label: 'A0' },
{ value: PageSize.A1, label: 'A1' },
{ value: PageSize.A2, label: 'A2' },
{ value: PageSize.A3, label: 'A3' },
{ value: PageSize.A4, label: 'A4' },
{ value: PageSize.A5, label: 'A5' },
{ value: PageSize.A6, label: 'A6' },
{ value: PageSize.LETTER, label: t('adjustPageScale.pageSize.letter', 'Letter') },
{ value: PageSize.LEGAL, label: t('adjustPageScale.pageSize.legal', 'Legal') },
];
return (
<Stack gap="md">
<NumberInput
label={t('adjustPageScale.scaleFactor.label', 'Scale Factor')}
value={parameters.scaleFactor}
onChange={(value) => onParameterChange('scaleFactor', typeof value === 'number' ? value : 1.0)}
min={0.1}
max={10.0}
step={0.1}
decimalScale={2}
disabled={disabled}
/>
<Select
label={t('adjustPageScale.pageSize.label', 'Target Page Size')}
value={parameters.pageSize}
onChange={(value) => {
if (value && Object.values(PageSize).includes(value as PageSize)) {
onParameterChange('pageSize', value as PageSize);
}
}}
data={pageSizeOptions}
disabled={disabled}
/>
</Stack>
);
};
export default AdjustPageScaleSettings;

View File

@ -4,7 +4,7 @@ import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissi
interface ChangePermissionsSettingsProps {
parameters: ChangePermissionsParameters;
onParameterChange: <K extends keyof ChangePermissionsParameters>(key: K, value: ChangePermissionsParameters[K]) => void;
onParameterChange: (key: keyof ChangePermissionsParameters, value: boolean) => void;
disabled?: boolean;
}

View File

@ -1,12 +1,11 @@
import React, { useState } from "react";
import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
import ButtonSelector from "../../shared/ButtonSelector";
interface CompressSettingsProps {
parameters: CompressParameters;
onParameterChange: <K extends keyof CompressParameters>(key: K, value: CompressParameters[K]) => void;
onParameterChange: (key: keyof CompressParameters, value: any) => void;
disabled?: boolean;
}
@ -19,16 +18,33 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
<Divider ml='-md'></Divider>
{/* Compression Method */}
<ButtonSelector
label={t('compress.method.title', 'Compression Method')}
value={parameters.compressionMethod}
onChange={(value) => onParameterChange('compressionMethod', value)}
options={[
{ value: 'quality', label: t('compress.method.quality', 'Quality') },
{ value: 'filesize', label: t('compress.method.filesize', 'File Size') },
]}
disabled={disabled}
/>
<Stack gap="sm">
<Text size="sm" fw={500}>Compression Method</Text>
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'quality')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
Quality
</div>
</Button>
<Button
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'filesize')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
File Size
</div>
</Button>
</div>
</Stack>
{/* Quality Adjustment */}
{parameters.compressionMethod === 'quality' && (

View File

@ -5,40 +5,40 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
interface ConvertFromEmailSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertFromEmailSettings = ({
parameters,
onParameterChange,
disabled = false
const ConvertFromEmailSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromEmailSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="email-settings">
<Text size="sm" fw={500}>{t("convert.emailOptions", "Email to PDF Options")}:</Text>
<Checkbox
label={t("convert.includeAttachments", "Include email attachments")}
checked={parameters.emailOptions.includeAttachments}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAttachments: event.currentTarget.checked
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAttachments: event.currentTarget.checked
})}
disabled={disabled}
data-testid="include-attachments-checkbox"
/>
{parameters.emailOptions.includeAttachments && (
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}:</Text>
<NumberInput
value={parameters.emailOptions.maxAttachmentSizeMB}
onChange={(value) => onParameterChange('emailOptions', {
...parameters.emailOptions,
maxAttachmentSizeMB: Number(value) || 10
onChange={(value) => onParameterChange('emailOptions', {
...parameters.emailOptions,
maxAttachmentSizeMB: Number(value) || 10
})}
min={1}
max={100}
@ -48,24 +48,24 @@ const ConvertFromEmailSettings = ({
/>
</Stack>
)}
<Checkbox
label={t("convert.includeAllRecipients", "Include CC and BCC recipients in header")}
checked={parameters.emailOptions.includeAllRecipients}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAllRecipients: event.currentTarget.checked
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
includeAllRecipients: event.currentTarget.checked
})}
disabled={disabled}
data-testid="include-all-recipients-checkbox"
/>
<Checkbox
label={t("convert.downloadHtml", "Download HTML intermediate file instead of PDF")}
checked={parameters.emailOptions.downloadHtml}
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
downloadHtml: event.currentTarget.checked
onChange={(event) => onParameterChange('emailOptions', {
...parameters.emailOptions,
downloadHtml: event.currentTarget.checked
})}
disabled={disabled}
data-testid="download-html-checkbox"
@ -74,4 +74,4 @@ const ConvertFromEmailSettings = ({
);
};
export default ConvertFromEmailSettings;
export default ConvertFromEmailSettings;

View File

@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
interface ConvertFromImageSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}

View File

@ -5,28 +5,28 @@ import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParame
interface ConvertFromWebSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}
const ConvertFromWebSettings = ({
parameters,
onParameterChange,
disabled = false
const ConvertFromWebSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromWebSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="web-settings">
<Text size="sm" fw={500}>{t("convert.webOptions", "Web to PDF Options")}:</Text>
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.zoomLevel", "Zoom Level")}:</Text>
<NumberInput
value={parameters.htmlOptions.zoomLevel}
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: Number(value) || 1.0
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: Number(value) || 1.0
})}
min={0.1}
max={3.0}
@ -36,9 +36,9 @@ const ConvertFromWebSettings = ({
/>
<Slider
value={parameters.htmlOptions.zoomLevel}
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: value
onChange={(value) => onParameterChange('htmlOptions', {
...parameters.htmlOptions,
zoomLevel: value
})}
min={0.1}
max={3.0}
@ -51,4 +51,4 @@ const ConvertFromWebSettings = ({
);
};
export default ConvertFromWebSettings;
export default ConvertFromWebSettings;

View File

@ -26,7 +26,7 @@ import { StirlingFile } from "../../../types/fileContext";
interface ConvertSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: StirlingFile[];
disabled?: boolean;

View File

@ -6,7 +6,7 @@ import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParame
interface ConvertToImageSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
disabled?: boolean;
}

View File

@ -7,16 +7,16 @@ import { StirlingFile } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
selectedFiles: StirlingFile[];
disabled?: boolean;
}
const ConvertToPdfaSettings = ({
parameters,
const ConvertToPdfaSettings = ({
parameters,
onParameterChange,
selectedFiles,
disabled = false
disabled = false
}: ConvertToPdfaSettingsProps) => {
const { t } = useTranslation();
const { hasDigitalSignatures, isChecking } = usePdfSignatureDetection(selectedFiles);
@ -29,7 +29,7 @@ const ConvertToPdfaSettings = ({
return (
<Stack gap="sm" data-testid="pdfa-settings">
<Text size="sm" fw={500}>{t("convert.pdfaOptions", "PDF/A Options")}:</Text>
{hasDigitalSignatures && (
<Alert color="yellow">
<Text size="sm">
@ -37,14 +37,14 @@ const ConvertToPdfaSettings = ({
</Text>
</Alert>
)}
<Stack gap="xs">
<Text size="xs" fw={500}>{t("convert.outputFormat", "Output Format")}:</Text>
<Select
value={parameters.pdfaOptions.outputFormat}
onChange={(value) => onParameterChange('pdfaOptions', {
...parameters.pdfaOptions,
outputFormat: value || 'pdfa-1'
onChange={(value) => onParameterChange('pdfaOptions', {
...parameters.pdfaOptions,
outputFormat: value || 'pdfa-1'
})}
data={pdfaFormatOptions}
disabled={disabled || isChecking}
@ -58,4 +58,4 @@ const ConvertToPdfaSettings = ({
);
};
export default ConvertToPdfaSettings;
export default ConvertToPdfaSettings;

View File

@ -1,182 +0,0 @@
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 }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('MergeFileSorter', () => {
const mockOnSortFiles = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render sort options dropdown, direction toggle, and sort button', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// 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(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument();
});
test('should have filename selected by default', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
const select = screen.getByRole('textbox');
expect(select).toHaveValue('mock-merge.sortBy.filename');
});
test('should show ascending direction by default', () => {
render(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// 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(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
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(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
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(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
// 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(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
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(
<TestWrapper>
<MergeFileSorter onSortFiles={mockOnSortFiles} />
</TestWrapper>
);
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);
});
});

View File

@ -1,77 +0,0 @@
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<MergeFileSorterProps> = ({
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 (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")}
</Text>
<Stack gap="xs">
<Group gap="xs" align="end" justify="space-between">
<Select
data={sortOptions}
value={sortType}
onChange={(value) => setSortType(value as 'filename' | 'dateModified')}
disabled={disabled}
label={t('merge.sortBy.label', 'Sort By')}
size='xs'
style={{ flex: 1 }}
/>
<ActionIcon
variant="light"
size="md"
onClick={handleDirectionToggle}
disabled={disabled}
title={ascending ? t('merge.sortBy.ascending', 'Ascending') : t('merge.sortBy.descending', 'Descending')}
>
{ascending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />}
</ActionIcon>
</Group>
<Button
variant="light"
size="xs"
leftSection={<SortIcon />}
onClick={handleSort}
disabled={disabled}
fullWidth
>
{t('merge.sortBy.sort', 'Sort')}
</Button>
</Stack>
</Stack>
);
};
export default MergeFileSorter;

View File

@ -1,100 +0,0 @@
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 }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('MergeSettings', () => {
const defaultParameters: MergeParameters = {
removeDigitalSignature: false,
generateTableOfContents: false,
};
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render both merge option checkboxes', () => {
render(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// 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(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
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(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
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(
<TestWrapper>
<MergeSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// 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?');
});
});

View File

@ -1,38 +0,0 @@
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: <K extends keyof MergeParameters>(key: K, value: MergeParameters[K]) => void;
disabled?: boolean;
}
const MergeSettings: React.FC<MergeSettingsProps> = ({
parameters,
onParameterChange,
disabled = false,
}) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Checkbox
label={t('merge.removeDigitalSignature', 'Remove digital signature in the merged file?')}
checked={parameters.removeDigitalSignature}
onChange={(event) => onParameterChange('removeDigitalSignature', event.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t('merge.generateTableOfContents', 'Generate table of contents in the merged file?')}
checked={parameters.generateTableOfContents}
onChange={(event) => onParameterChange('generateTableOfContents', event.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
};
export default MergeSettings;

View File

@ -16,7 +16,7 @@ interface AdvancedOption {
interface AdvancedOCRSettingsProps {
advancedOptions: string[];
ocrRenderType?: string;
onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
onParameterChange: (key: keyof OCRParameters, value: any) => void;
disabled?: boolean;
}
@ -40,7 +40,7 @@ const AdvancedOCRSettings: React.FC<AdvancedOCRSettingsProps> = ({
// Handle individual checkbox changes
const handleCheckboxChange = (optionValue: string, checked: boolean) => {
const option = advancedOptionsData.find(opt => opt.value === optionValue);
if (option?.isSpecial) {
// Handle special options (like compatibility mode) differently
if (optionValue === 'compatibilityMode') {
@ -69,7 +69,7 @@ const AdvancedOCRSettings: React.FC<AdvancedOCRSettingsProps> = ({
<Text size="sm" fw={500} mb="md">
{t('ocr.settings.advancedOptions.label', 'Processing Options')}
</Text>
<Stack gap="sm">
{advancedOptionsData.map((option) => (
<Checkbox
@ -87,4 +87,4 @@ const AdvancedOCRSettings: React.FC<AdvancedOCRSettingsProps> = ({
);
};
export default AdvancedOCRSettings;
export default AdvancedOCRSettings;

View File

@ -6,7 +6,7 @@ import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
interface OCRSettingsProps {
parameters: OCRParameters;
onParameterChange: <K extends keyof OCRParameters>(key: K, value: OCRParameters[K]) => void;
onParameterChange: (key: keyof OCRParameters, value: any) => void;
disabled?: boolean;
}

View File

@ -1,211 +0,0 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import RedactAdvancedSettings from './RedactAdvancedSettings';
import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
// Mock useTranslation
const mockT = vi.fn((_key: string, fallback: string) => fallback);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('RedactAdvancedSettings', () => {
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render all advanced settings controls', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
expect(screen.getByText('Box Colour')).toBeInTheDocument();
expect(screen.getByText('Custom Extra Padding')).toBeInTheDocument();
expect(screen.getByText('Use Regex')).toBeInTheDocument();
expect(screen.getByText('Whole Word Search')).toBeInTheDocument();
expect(screen.getByText('Convert PDF to PDF-Image (Used to remove text behind the box)')).toBeInTheDocument();
});
test('should display current parameter values', () => {
const customParameters = {
...defaultParameters,
redactColor: '#FF0000',
customPadding: 0.5,
useRegex: true,
wholeWordSearch: true,
convertPDFToImage: false,
};
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check color input value
const colorInput = screen.getByDisplayValue('#FF0000');
expect(colorInput).toBeInTheDocument();
// Check number input value
const paddingInput = screen.getByDisplayValue('0.5');
expect(paddingInput).toBeInTheDocument();
// Check checkbox states
const useRegexCheckbox = screen.getByLabelText('Use Regex');
const wholeWordCheckbox = screen.getByLabelText('Whole Word Search');
const convertCheckbox = screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)');
expect(useRegexCheckbox).toBeChecked();
expect(wholeWordCheckbox).toBeChecked();
expect(convertCheckbox).not.toBeChecked();
});
test('should call onParameterChange when color is changed', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const colorInput = screen.getByDisplayValue('#000000');
fireEvent.change(colorInput, { target: { value: '#FF0000' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
});
test('should call onParameterChange when padding is changed', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const paddingInput = screen.getByDisplayValue('0.1');
fireEvent.change(paddingInput, { target: { value: '0.5' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.5);
});
test('should handle invalid padding values', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const paddingInput = screen.getByDisplayValue('0.1');
// Simulate NumberInput onChange with invalid value (empty string)
const numberInput = paddingInput.closest('.mantine-NumberInput-root');
if (numberInput) {
// Find the input and trigger change with empty value
fireEvent.change(paddingInput, { target: { value: '' } });
// The component should default to 0.1 for invalid values
expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.1);
}
});
test.each([
{
paramName: 'useRegex' as const,
label: 'Use Regex',
initialValue: false,
expectedValue: true,
},
{
paramName: 'wholeWordSearch' as const,
label: 'Whole Word Search',
initialValue: false,
expectedValue: true,
},
{
paramName: 'convertPDFToImage' as const,
label: 'Convert PDF to PDF-Image (Used to remove text behind the box)',
initialValue: true,
expectedValue: false,
},
])('should call onParameterChange when $paramName checkbox is toggled', ({ paramName, label, initialValue, expectedValue }) => {
const customParameters = {
...defaultParameters,
[paramName]: initialValue,
};
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
const checkbox = screen.getByLabelText(label);
fireEvent.click(checkbox);
expect(mockOnParameterChange).toHaveBeenCalledWith(paramName, expectedValue);
});
test.each([
{ controlType: 'color input', getValue: () => screen.getByDisplayValue('#000000') },
{ controlType: 'padding input', getValue: () => screen.getByDisplayValue('0.1') },
{ controlType: 'useRegex checkbox', getValue: () => screen.getByLabelText('Use Regex') },
{ controlType: 'wholeWordSearch checkbox', getValue: () => screen.getByLabelText('Whole Word Search') },
{ controlType: 'convertPDFToImage checkbox', getValue: () => screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)') },
])('should disable $controlType when disabled prop is true', ({ getValue }) => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
const control = getValue();
expect(control).toBeDisabled();
});
test('should have correct padding input constraints', () => {
render(
<TestWrapper>
<RedactAdvancedSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// NumberInput in Mantine might not expose these attributes directly on the input element
// Instead, check that the NumberInput component is rendered with correct placeholder
const paddingInput = screen.getByPlaceholderText('0.1');
expect(paddingInput).toBeInTheDocument();
expect(paddingInput).toHaveDisplayValue('0.1');
});
});

View File

@ -1,69 +0,0 @@
import { Stack, NumberInput, ColorInput, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters";
interface RedactAdvancedSettingsProps {
parameters: RedactParameters;
onParameterChange: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
disabled?: boolean;
}
const RedactAdvancedSettings = ({ parameters, onParameterChange, disabled = false }: RedactAdvancedSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
{/* Box Color */}
<ColorInput
label={t('redact.auto.colorLabel', 'Box Colour')}
value={parameters.redactColor}
onChange={(value) => onParameterChange('redactColor', value)}
disabled={disabled}
size="sm"
format="hex"
/>
{/* Box Padding */}
<NumberInput
label={t('redact.auto.customPaddingLabel', 'Custom Extra Padding')}
value={parameters.customPadding}
onChange={(value) => onParameterChange('customPadding', typeof value === 'number' ? value : 0.1)}
min={0}
max={10}
step={0.1}
disabled={disabled}
size="sm"
placeholder="0.1"
/>
{/* Use Regex */}
<Checkbox
label={t('redact.auto.useRegexLabel', 'Use Regex')}
checked={parameters.useRegex}
onChange={(e) => onParameterChange('useRegex', e.currentTarget.checked)}
disabled={disabled}
size="sm"
/>
{/* Whole Word Search */}
<Checkbox
label={t('redact.auto.wholeWordSearchLabel', 'Whole Word Search')}
checked={parameters.wholeWordSearch}
onChange={(e) => onParameterChange('wholeWordSearch', e.currentTarget.checked)}
disabled={disabled}
size="sm"
/>
{/* Convert PDF to PDF-Image */}
<Checkbox
label={t('redact.auto.convertPDFToImageLabel', 'Convert PDF to PDF-Image (Used to remove text behind the box)')}
checked={parameters.convertPDFToImage}
onChange={(e) => onParameterChange('convertPDFToImage', e.currentTarget.checked)}
disabled={disabled}
size="sm"
/>
</Stack>
);
};
export default RedactAdvancedSettings;

View File

@ -1,33 +0,0 @@
import { useTranslation } from 'react-i18next';
import { RedactMode } from '../../../hooks/tools/redact/useRedactParameters';
import ButtonSelector from '../../shared/ButtonSelector';
interface RedactModeSelectorProps {
mode: RedactMode;
onModeChange: (mode: RedactMode) => void;
disabled?: boolean;
}
export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) {
const { t } = useTranslation();
return (
<ButtonSelector
label={t('redact.modeSelector.mode', 'Mode')}
value={mode}
onChange={onModeChange}
options={[
{
value: 'automatic' as const,
label: t('redact.modeSelector.automatic', 'Automatic'),
},
{
value: 'manual' as const,
label: t('redact.modeSelector.manual', 'Manual'),
disabled: true, // Keep manual mode disabled until implemented
},
]}
disabled={disabled}
/>
);
}

View File

@ -1,183 +0,0 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import RedactSingleStepSettings from './RedactSingleStepSettings';
import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
// Mock useTranslation
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('RedactSingleStepSettings', () => {
const mockOnParameterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render mode selector', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
expect(screen.getByText('Mode')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Manual' })).toBeInTheDocument();
});
test('should render automatic mode settings when mode is automatic', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Default mode is automatic, so these should be visible
expect(screen.getByText('Words to Redact')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
expect(screen.getByText('Box Colour')).toBeInTheDocument();
expect(screen.getByText('Use Regex')).toBeInTheDocument();
});
test('should render manual mode settings when mode is manual', () => {
const manualParameters = {
...defaultParameters,
mode: 'manual' as const,
};
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={manualParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Manual mode should show placeholder text
expect(screen.getByText('Manual redaction interface will be available here when implemented.')).toBeInTheDocument();
// Automatic mode settings should not be visible
expect(screen.queryByText('Words to Redact')).not.toBeInTheDocument();
});
test('should pass through parameter changes from automatic settings', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Test adding a word
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
fireEvent.click(addButton);
expect(mockOnParameterChange).toHaveBeenCalledWith('wordsToRedact', ['TestWord']);
});
test('should pass through parameter changes from advanced settings', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Test changing color
const colorInput = screen.getByDisplayValue('#000000');
fireEvent.change(colorInput, { target: { value: '#FF0000' } });
expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
});
test('should disable all controls when disabled prop is true', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
disabled={true}
/>
</TestWrapper>
);
// Mode selector buttons should be disabled
expect(screen.getByRole('button', { name: 'Automatic' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Manual' })).toBeDisabled();
// Automatic settings controls should be disabled
expect(screen.getByPlaceholderText('Enter a word')).toBeDisabled();
expect(screen.getByRole('button', { name: '+ Add' })).toBeDisabled();
expect(screen.getByDisplayValue('#000000')).toBeDisabled();
});
test('should show current parameter values in automatic mode', () => {
const customParameters = {
...defaultParameters,
wordsToRedact: ['Word1', 'Word2'],
redactColor: '#FF0000',
useRegex: true,
customPadding: 0.5,
};
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={customParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check that word tags are displayed
expect(screen.getByText('Word1')).toBeInTheDocument();
expect(screen.getByText('Word2')).toBeInTheDocument();
// Check that color is displayed
expect(screen.getByDisplayValue('#FF0000')).toBeInTheDocument();
// Check that regex checkbox is checked
const useRegexCheckbox = screen.getByLabelText('Use Regex');
expect(useRegexCheckbox).toBeChecked();
// Check that padding value is displayed
expect(screen.getByDisplayValue('0.5')).toBeInTheDocument();
});
test('should maintain consistent spacing and layout', () => {
render(
<TestWrapper>
<RedactSingleStepSettings
parameters={defaultParameters}
onParameterChange={mockOnParameterChange}
/>
</TestWrapper>
);
// Check that the Stack container exists
const container = screen.getByText('Mode').closest('.mantine-Stack-root');
expect(container).toBeInTheDocument();
});
});

View File

@ -1,61 +0,0 @@
import { Stack, Divider } from "@mantine/core";
import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters";
import RedactModeSelector from "./RedactModeSelector";
import WordsToRedactInput from "./WordsToRedactInput";
import RedactAdvancedSettings from "./RedactAdvancedSettings";
interface RedactSingleStepSettingsProps {
parameters: RedactParameters;
onParameterChange: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
disabled?: boolean;
}
const RedactSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: RedactSingleStepSettingsProps) => {
return (
<Stack gap="md">
{/* Mode Selection */}
<RedactModeSelector
mode={parameters.mode}
onModeChange={(mode) => onParameterChange('mode', mode)}
disabled={disabled}
/>
{/* Automatic Mode Settings */}
{parameters.mode === 'automatic' && (
<>
<Divider />
{/* Words to Redact */}
<WordsToRedactInput
wordsToRedact={parameters.wordsToRedact}
onWordsChange={(words) => onParameterChange('wordsToRedact', words)}
disabled={disabled}
/>
<Divider />
{/* Advanced Settings */}
<RedactAdvancedSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* Manual Mode Placeholder */}
{parameters.mode === 'manual' && (
<>
<Divider />
<Stack gap="md">
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
Manual redaction interface will be available here when implemented.
</div>
</Stack>
</>
)}
</Stack>
);
};
export default RedactSingleStepSettings;

View File

@ -1,191 +0,0 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import WordsToRedactInput from './WordsToRedactInput';
// Mock useTranslation
const mockT = vi.fn((_key: string, fallback: string) => fallback);
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mockT })
}));
// Wrapper component to provide Mantine context
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('WordsToRedactInput', () => {
const mockOnWordsChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test('should render with title and input field', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
expect(screen.getByText('Words to Redact')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '+ Add' })).toBeInTheDocument();
});
test.each([
{ trigger: 'Add button click', action: (_input: HTMLElement, addButton: HTMLElement) => fireEvent.click(addButton) },
{ trigger: 'Enter key press', action: (input: HTMLElement) => fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) },
])('should add word when $trigger', ({ action }) => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
action(input, addButton);
expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
});
test('should not add empty word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.click(addButton);
expect(mockOnWordsChange).not.toHaveBeenCalled();
});
test('should not add duplicate word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Existing']}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'Existing' } });
fireEvent.click(addButton);
expect(mockOnWordsChange).not.toHaveBeenCalled();
});
test('should trim whitespace when adding word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: ' TestWord ' } });
fireEvent.click(addButton);
expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
});
test('should remove word when x button is clicked', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Word1', 'Word2']}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const removeButtons = screen.getAllByText('×');
fireEvent.click(removeButtons[0]);
expect(mockOnWordsChange).toHaveBeenCalledWith(['Word2']);
});
test('should clear input after adding word', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word') as HTMLInputElement;
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: 'TestWord' } });
fireEvent.click(addButton);
expect(input.value).toBe('');
});
test.each([
{ description: 'disable Add button when input is empty', inputValue: '', expectedDisabled: true },
{ description: 'enable Add button when input has text', inputValue: 'TestWord', expectedDisabled: false },
])('should $description', ({ inputValue, expectedDisabled }) => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={[]}
onWordsChange={mockOnWordsChange}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
fireEvent.change(input, { target: { value: inputValue } });
expect(addButton).toHaveProperty('disabled', expectedDisabled);
});
test('should disable all controls when disabled prop is true', () => {
render(
<TestWrapper>
<WordsToRedactInput
wordsToRedact={['Word1']}
onWordsChange={mockOnWordsChange}
disabled={true}
/>
</TestWrapper>
);
const input = screen.getByPlaceholderText('Enter a word');
const addButton = screen.getByRole('button', { name: '+ Add' });
const removeButton = screen.getByText('×');
expect(input).toBeDisabled();
expect(addButton).toBeDisabled();
expect(removeButton.closest('button')).toBeDisabled();
});
});

View File

@ -1,99 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, TextInput, Button, Group, ActionIcon } from '@mantine/core';
interface WordsToRedactInputProps {
wordsToRedact: string[];
onWordsChange: (words: string[]) => void;
disabled?: boolean;
}
export default function WordsToRedactInput({ wordsToRedact, onWordsChange, disabled }: WordsToRedactInputProps) {
const { t } = useTranslation();
const [currentWord, setCurrentWord] = useState('');
const addWord = () => {
if (currentWord.trim() && !wordsToRedact.includes(currentWord.trim())) {
onWordsChange([...wordsToRedact, currentWord.trim()]);
setCurrentWord('');
}
};
const removeWord = (index: number) => {
onWordsChange(wordsToRedact.filter((_, i) => i !== index));
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addWord();
}
};
return (
<Stack gap="sm">
<Text size="sm" fw={500}>
{t('redact.auto.wordsToRedact.title', 'Words to Redact')}
</Text>
{/* Current words */}
{wordsToRedact.map((word, index) => (
<Group key={index} justify="space-between" p="sm" style={{
borderRadius: 'var(--mantine-radius-sm)',
border: `1px solid var(--mantine-color-gray-3)`,
backgroundColor: 'var(--mantine-color-gray-0)'
}}>
<Text
size="sm"
style={{
maxWidth: '80%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={word}
>
{word}
</Text>
<ActionIcon
size="sm"
variant="subtle"
color="red"
onClick={() => removeWord(index)}
disabled={disabled}
>
×
</ActionIcon>
</Group>
))}
{/* Add new word input */}
<Group gap="sm" align="end">
<TextInput
placeholder={t('redact.auto.wordsToRedact.placeholder', 'Enter a word')}
value={currentWord}
onChange={(e) => setCurrentWord(e.target.value)}
onKeyDown={handleKeyPress}
disabled={disabled}
size="sm"
style={{ flex: 1 }}
/>
<Button
size="sm"
variant="light"
onClick={addWord}
disabled={disabled || !currentWord.trim()}
>
+ {t('redact.auto.wordsToRedact.add', 'Add')}
</Button>
</Group>
{/* Examples */}
{wordsToRedact.length === 0 && (
<Text size="xs" c="dimmed">
{t('redact.auto.wordsToRedact.examples', 'Examples: Confidential, Top-Secret')}
</Text>
)}
</Stack>
);
}

View File

@ -4,7 +4,7 @@ import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/us
interface RemovePasswordSettingsProps {
parameters: RemovePasswordParameters;
onParameterChange: <K extends keyof RemovePasswordParameters>(key: K, value: RemovePasswordParameters[K]) => void;
onParameterChange: (key: keyof RemovePasswordParameters, value: string) => void;
disabled?: boolean;
}

View File

@ -4,7 +4,7 @@ import { SanitizeParameters, defaultParameters } from "../../../hooks/tools/sani
interface SanitizeSettingsProps {
parameters: SanitizeParameters;
onParameterChange: <K extends keyof SanitizeParameters>(key: K, value: SanitizeParameters[K]) => void;
onParameterChange: (key: keyof SanitizeParameters, value: boolean) => void;
disabled?: boolean;
}

View File

@ -10,12 +10,11 @@ import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: StirlingFile[];
minFiles?: number;
placeholder?: string;
}
const FileStatusIndicator = ({
selectedFiles = [],
minFiles = 1,
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext();
@ -56,14 +55,6 @@ 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
@ -98,12 +89,12 @@ const FileStatusIndicator = ({
}
// Show selection status when there are files in workbench
if (selectedFiles.length < minFiles) {
if (selectedFiles.length === 0) {
// If no recent files, show upload option
if (!hasRecentFiles) {
return (
<Text size="sm" c="dimmed">
{getPlaceholder() + " "}
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={handleNativeUpload}
@ -118,7 +109,7 @@ const FileStatusIndicator = ({
// If there are recent files, show add files option
return (
<Text size="sm" c="dimmed">
{getPlaceholder() + " "}
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={() => openFilesModal()}
@ -134,7 +125,7 @@ const FileStatusIndicator = ({
return (
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{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 })}
</Text>
);
};

View File

@ -7,7 +7,7 @@ export interface FilesToolStepProps {
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
onCollapsedClick?: () => void;
minFiles?: number;
placeholder?: string;
}
export function createFilesToolStep(
@ -23,7 +23,7 @@ export function createFilesToolStep(
}, (
<FileStatusIndicator
selectedFiles={props.selectedFiles}
minFiles={props.minFiles}
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
/>
));
}

View File

@ -53,7 +53,7 @@ const renderTooltipTitle = (
<Text fw={400} size="sm">
{title}
</Text>
<LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
</Flex>
</Tooltip>
);

View File

@ -22,7 +22,7 @@ export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowT
<Text fw={500} size="lg" p="xs">
{title}
</Text>
{tooltip && <LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />}
{tooltip && <LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />}
</Flex>
);

View File

@ -9,7 +9,7 @@ import { StirlingFile } from '../../../types/fileContext';
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
isCollapsed?: boolean;
minFiles?: number;
placeholder?: string;
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,
minFiles: config.files.minFiles,
placeholder: config.files.placeholder,
onCollapsedClick: config.files.onCollapsedClick
})}

View File

@ -1,11 +1,11 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { isSplitMode, isSplitType, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
export interface SplitSettingsProps {
parameters: SplitParameters;
onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
disabled?: boolean;
}
@ -62,7 +62,7 @@ const SplitSettings = ({
<Select
label={t("split-by-size-or-count.type.label", "Split Type")}
value={parameters.splitType}
onChange={(v) => isSplitType(v) && onParameterChange('splitType', v)}
onChange={(v) => v && onParameterChange('splitType', v)}
disabled={disabled}
data={[
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },

View File

@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useAdjustPageScaleTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("adjustPageScale.tooltip.header.title", "Page Scale Settings Overview")
},
tips: [
{
title: t("adjustPageScale.tooltip.description.title", "Description"),
description: t("adjustPageScale.tooltip.description.text", "Adjust the size of PDF content and change the page dimensions.")
},
{
title: t("adjustPageScale.tooltip.scaleFactor.title", "Scale Factor"),
description: t("adjustPageScale.tooltip.scaleFactor.text", "Controls how large or small the content appears on the page. Content is scaled and centered - if scaled content is larger than the page size, it may be cropped."),
bullets: [
t("adjustPageScale.tooltip.scaleFactor.bullet1", "1.0 = Original size"),
t("adjustPageScale.tooltip.scaleFactor.bullet2", "0.5 = Half size (50% smaller)"),
t("adjustPageScale.tooltip.scaleFactor.bullet3", "2.0 = Double size (200% larger, may crop)")
]
},
{
title: t("adjustPageScale.tooltip.pageSize.title", "Target Page Size"),
description: t("adjustPageScale.tooltip.pageSize.text", "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, while other options resize to standard paper sizes.")
}
]
};
};

View File

@ -1,19 +0,0 @@
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.')
}
]
};
};

View File

@ -1,79 +0,0 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useRedactModeTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("redact.tooltip.mode.header.title", "Redaction Method")
},
tips: [
{
title: t("redact.tooltip.mode.automatic.title", "Automatic Redaction"),
description: t("redact.tooltip.mode.automatic.text", "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, SSNs, or confidential markers.")
},
{
title: t("redact.tooltip.mode.manual.title", "Manual Redaction"),
description: t("redact.tooltip.mode.manual.text", "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)")
}
]
};
};
export const useRedactWordsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("redact.tooltip.words.header.title", "Words to Redact")
},
tips: [
{
title: t("redact.tooltip.words.description.title", "Text Matching"),
description: t("redact.tooltip.words.description.text", "Enter words or phrases to find and redact in your document. Each word will be searched for separately."),
bullets: [
t("redact.tooltip.words.bullet1", "Add one word at a time"),
t("redact.tooltip.words.bullet2", "Press Enter or click 'Add Another' to add"),
t("redact.tooltip.words.bullet3", "Click × to remove words")
]
},
{
title: t("redact.tooltip.words.examples.title", "Common Examples"),
description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.")
}
]
};
};
export const useRedactAdvancedTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("redact.tooltip.advanced.header.title", "Advanced Redaction Settings")
},
tips: [
{
title: t("redact.tooltip.advanced.color.title", "Box Colour & Padding"),
description: t("redact.tooltip.advanced.color.text", "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."),
},
{
title: t("redact.tooltip.advanced.regex.title", "Use Regex"),
description: t("redact.tooltip.advanced.regex.text", "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns."),
bullets: [
t("redact.tooltip.advanced.regex.bullet1", "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format"),
t("redact.tooltip.advanced.regex.bullet2", "Use with caution - test thoroughly")
]
},
{
title: t("redact.tooltip.advanced.wholeWord.title", "Whole Word Search"),
description: t("redact.tooltip.advanced.wholeWord.text", "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled.")
},
{
title: t("redact.tooltip.advanced.convert.title", "Convert to PDF-Image"),
description: t("redact.tooltip.advanced.convert.text", "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable.")
}
]
};
};

View File

@ -6,11 +6,9 @@ import CloseIcon from '@mui/icons-material/Close';
import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { ViewerProvider, useViewer } from "../../contexts/ViewerContext";
import { LocalEmbedPDF } from './LocalEmbedPDF';
import { PdfViewerToolbar } from './PdfViewerToolbar';
import { ThumbnailSidebar } from './ThumbnailSidebar';
import '../../types/embedPdf';
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
@ -19,7 +17,7 @@ export interface EmbedPdfViewerProps {
previewFile?: File | null;
}
const EmbedPdfViewerContent = ({
const EmbedPdfViewer = ({
sidebarsVisible,
setSidebarsVisible,
onClose,
@ -30,7 +28,7 @@ const EmbedPdfViewerContent = ({
const { colorScheme } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar } = useViewer();
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = React.useState(false);
// Get current file from FileContext
const { selectors } = useFileState();
@ -70,7 +68,7 @@ const EmbedPdfViewerContent = ({
event.preventDefault();
event.stopPropagation();
const zoomAPI = window.embedPdfZoom;
const zoomAPI = (window as any).embedPdfZoom;
if (zoomAPI) {
if (event.deltaY < 0) {
// Scroll up - zoom in
@ -99,7 +97,7 @@ const EmbedPdfViewerContent = ({
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
const zoomAPI = window.embedPdfZoom;
const zoomAPI = (window as any).embedPdfZoom;
if (zoomAPI) {
if (event.key === '=' || event.key === '+') {
// Ctrl+= or Ctrl++ for zoom in
@ -122,12 +120,14 @@ const EmbedPdfViewerContent = ({
// Expose toggle functions globally for right rail buttons
React.useEffect(() => {
window.toggleThumbnailSidebar = toggleThumbnailSidebar;
(window as any).toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
};
return () => {
delete window.toggleThumbnailSidebar;
delete (window as any).toggleThumbnailSidebar;
};
}, [toggleThumbnailSidebar]);
}, []);
return (
<Box
@ -212,7 +212,7 @@ const EmbedPdfViewerContent = ({
}}
dualPage={false}
onDualPageToggle={() => {
window.embedPdfSpread?.toggleSpreadMode();
(window as any).embedPdfSpread?.toggleSpreadMode();
}}
currentZoom={100}
/>
@ -224,19 +224,11 @@ const EmbedPdfViewerContent = ({
{/* Thumbnail Sidebar */}
<ThumbnailSidebar
visible={isThumbnailSidebarVisible}
onToggle={toggleThumbnailSidebar}
onToggle={() => setIsThumbnailSidebarVisible(prev => !prev)}
colorScheme={colorScheme}
/>
</Box>
);
};
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
return (
<ViewerProvider>
<EmbedPdfViewerContent {...props} />
</ViewerProvider>
);
};
export default EmbedPdfViewer;

View File

@ -0,0 +1,687 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import LastPageIcon from "@mui/icons-material/LastPage";
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
import CloseIcon from "@mui/icons-material/Close";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { isFileObject } from "../../types/fileContext";
import { FileId } from "../../types/file";
// Lazy loading page image component
interface LazyPageImageProps {
pageIndex: number;
zoom: number;
theme: any;
isFirst: boolean;
renderPage: (pageIndex: number) => Promise<string | null>;
pageImages: (string | null)[];
setPageRef: (index: number, ref: HTMLImageElement | null) => void;
}
const LazyPageImage = ({
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
}: LazyPageImageProps) => {
const [isVisible, setIsVisible] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !imageUrl) {
setIsVisible(true);
}
});
},
{
rootMargin: '200px', // Start loading 200px before visible
threshold: 0.1
}
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, [imageUrl]);
// Update local state when pageImages changes (from preloading)
useEffect(() => {
if (pageImages[pageIndex]) {
setImageUrl(pageImages[pageIndex]);
}
}, [pageImages, pageIndex]);
useEffect(() => {
if (isVisible && !imageUrl) {
renderPage(pageIndex).then((url) => {
if (url) setImageUrl(url);
});
}
}, [isVisible, imageUrl, pageIndex, renderPage]);
useEffect(() => {
if (imgRef.current) {
setPageRef(pageIndex, imgRef.current);
}
}, [pageIndex, setPageRef]);
if (imageUrl) {
return (
<img
ref={imgRef}
src={imageUrl}
alt={`Page ${pageIndex + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
}}
/>
);
}
// Placeholder while loading
return (
<div
ref={imgRef}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
height: 800 * zoom, // Estimated height
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
border: '1px dashed #ccc'
}}
>
{isVisible ? (
<div style={{ textAlign: 'center' }}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 8px'
}} />
<Text size="sm" c="dimmed">Loading page {pageIndex + 1}...</Text>
</div>
) : (
<Text size="sm" c="dimmed">Page {pageIndex + 1}</Text>
)}
</div>
);
};
export interface ViewerProps {
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile: File | null; // For preview mode - bypasses context
}
const Viewer = ({
onClose,
previewFile,
}: ViewerProps) => {
const { t } = useTranslation();
const theme = useMantineTheme();
// Get current file from FileContext
const { selectors } = useFileState();
const activeFiles = selectors.getFiles();
// Tab management for multiple files
const [activeTab, setActiveTab] = useState<string>("0");
// Reset PDF state when switching tabs
const handleTabChange = (newTab: string) => {
setActiveTab(newTab);
setNumPages(0);
setPageImages([]);
setCurrentPage(null);
setLoading(true);
};
const [numPages, setNumPages] = useState<number>(0);
const [pageImages, setPageImages] = useState<string[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number | null>(null);
const [dualPage, setDualPage] = useState(false);
const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
// Memoize setPageRef to prevent infinite re-renders
const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
pageRefs.current[index] = ref;
}, []);
// Get files with URLs for tabs - we'll need to create these individually
const file0WithUrl = useFileWithUrl(activeFiles[0]);
const file1WithUrl = useFileWithUrl(activeFiles[1]);
const file2WithUrl = useFileWithUrl(activeFiles[2]);
const file3WithUrl = useFileWithUrl(activeFiles[3]);
const file4WithUrl = useFileWithUrl(activeFiles[4]);
const filesWithUrls = React.useMemo(() => {
return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl]
.slice(0, activeFiles.length)
.filter(Boolean);
}, [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl, activeFiles.length]);
// Use preview file if available, otherwise use active tab file
const effectiveFile = React.useMemo(() => {
if (previewFile) {
// Validate the preview file
if (!isFileObject(previewFile)) {
return null;
}
if (previewFile.size === 0) {
return null;
}
return { file: previewFile, url: null };
} else {
// Use the file from the active tab
const tabIndex = parseInt(activeTab);
return filesWithUrls[tabIndex] || null;
}
}, [previewFile, filesWithUrls, activeTab]);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const pdfDocRef = useRef<any>(null);
const renderingPagesRef = useRef<Set<number>>(new Set());
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
const preloadingRef = useRef<boolean>(false);
// Function to render a specific page on-demand
const renderPage = async (pageIndex: number): Promise<string | null> => {
if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
return null;
}
const pageNum = pageIndex + 1;
if (pageImages[pageIndex]) {
return pageImages[pageIndex]; // Already rendered
}
renderingPagesRef.current.add(pageIndex);
try {
const page = await pdfDocRef.current.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.2 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
if (ctx) {
await page.render({ canvasContext: ctx, viewport }).promise;
const dataUrl = canvas.toDataURL();
// Update the pageImages array
setPageImages(prev => {
const newImages = [...prev];
newImages[pageIndex] = dataUrl;
return newImages;
});
renderingPagesRef.current.delete(pageIndex);
return dataUrl;
}
} catch (error) {
console.error(`Failed to render page ${pageNum}:`, error);
}
renderingPagesRef.current.delete(pageIndex);
return null;
};
// Progressive preloading function
const startProgressivePreload = async () => {
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
preloadingRef.current = true;
// Start with first few pages for immediate viewing
const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages
// Render priority pages first
for (const pageIndex of priorityPages) {
if (pageIndex < numPages && !pageImages[pageIndex]) {
await renderPage(pageIndex);
// Small delay to allow UI to update
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// Then render remaining pages in background
for (let pageIndex = 5; pageIndex < numPages; pageIndex++) {
if (!pageImages[pageIndex]) {
await renderPage(pageIndex);
// Longer delay for background loading to not block UI
await new Promise(resolve => setTimeout(resolve, 100));
}
}
preloadingRef.current = false;
};
// Initialize current page when PDF loads
useEffect(() => {
if (numPages > 0 && !currentPage) {
setCurrentPage(1);
}
}, [numPages, currentPage]);
// Function to scroll to a specific page
const scrollToPage = (pageNumber: number) => {
const el = pageRefs.current[pageNumber - 1];
const scrollArea = scrollAreaRef.current;
if (el && scrollArea) {
const scrollAreaRect = scrollArea.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const currentScrollTop = scrollArea.scrollTop;
// Position page near top of viewport with some padding
const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20;
scrollArea.scrollTo({
top: targetScrollTop,
behavior: "smooth"
});
}
};
// Throttled scroll handler to prevent jerky updates
const handleScrollThrottled = useCallback(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea || !pageRefs.current.length) return;
const areaRect = scrollArea.getBoundingClientRect();
const viewportCenter = areaRect.top + areaRect.height / 2;
let closestIdx = 0;
let minDist = Infinity;
pageRefs.current.forEach((img, idx) => {
if (img) {
const imgRect = img.getBoundingClientRect();
const imgCenter = imgRect.top + imgRect.height / 2;
const dist = Math.abs(imgCenter - viewportCenter);
if (dist < minDist) {
minDist = dist;
closestIdx = idx;
}
}
});
// Update page number display only if changed
if (currentPage !== closestIdx + 1) {
setCurrentPage(closestIdx + 1);
}
}, [currentPage]);
// Throttle scroll events to reduce jerkiness
const handleScroll = useCallback(() => {
if (window.requestAnimationFrame) {
window.requestAnimationFrame(handleScrollThrottled);
} else {
handleScrollThrottled();
}
}, [handleScrollThrottled]);
useEffect(() => {
let cancelled = false;
async function loadPdfInfo() {
if (!effectiveFile) {
setNumPages(0);
setPageImages([]);
return;
}
setLoading(true);
try {
let pdfData;
// For preview files, use ArrayBuffer directly to avoid blob URL issues
if (previewFile && effectiveFile.file === previewFile) {
const arrayBuffer = await previewFile.arrayBuffer();
pdfData = { data: arrayBuffer };
}
// Handle special IndexedDB URLs for large files
else if (effectiveFile.url?.startsWith('indexeddb:')) {
const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
// Get data directly from IndexedDB
const arrayBuffer = await fileStorage.getFileData(fileId);
if (!arrayBuffer) {
throw new Error('File not found in IndexedDB - may have been purged by browser');
}
// Store reference for cleanup
currentArrayBufferRef.current = arrayBuffer;
pdfData = { data: arrayBuffer };
} else if (effectiveFile.url) {
// Standard blob URL or regular URL
pdfData = effectiveFile.url;
} else {
throw new Error('No valid PDF source available');
}
const pdf = await pdfWorkerManager.createDocument(pdfData);
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
setPageImages(new Array(pdf.numPages).fill(null));
// Start progressive preloading after a short delay
setTimeout(() => startProgressivePreload(), 100);
}
} catch {
if (!cancelled) {
setPageImages([]);
setNumPages(0);
}
}
if (!cancelled) setLoading(false);
}
loadPdfInfo();
return () => {
cancelled = true;
// Stop any ongoing preloading
preloadingRef.current = false;
// Cleanup PDF document using worker manager
if (pdfDocRef.current) {
pdfWorkerManager.destroyDocument(pdfDocRef.current);
pdfDocRef.current = null;
}
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
}, [effectiveFile, previewFile]);
useEffect(() => {
const viewport = scrollAreaRef.current;
if (!viewport) return;
const handler = () => {
handleScroll();
};
viewport.addEventListener("scroll", handler);
return () => viewport.removeEventListener("scroll", handler);
}, [pageImages]);
return (
<Box style={{ position: 'relative', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Close Button - Only show in preview mode */}
{onClose && previewFile && (
<ActionIcon
variant="filled"
color="gray"
size="lg"
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
borderRadius: '50%',
}}
onClick={onClose}
>
<CloseIcon />
</ActionIcon>
)}
{!effectiveFile ? (
<Center style={{ flex: 1 }}>
<Text c="red">Error: No file provided to viewer</Text>
</Center>
) : (
<>
{/* Tabs for multiple files */}
{activeFiles.length > 1 && !previewFile && (
<Box
style={{
borderBottom: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
position: 'relative',
zIndex: 100,
marginTop: '60px' // Push tabs below TopControls
}}
>
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
<Tabs.List>
{activeFiles.map((file: any, index: number) => (
<Tabs.Tab key={index} value={index.toString()}>
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
</Box>
)}
{loading ? (
<div style={{ flex: 1, padding: '1rem' }}>
<SkeletonLoader type="viewer" />
</div>
) : (
<ScrollArea
style={{ flex: 1, position: "relative"}}
viewportRef={scrollAreaRef}
>
<Stack gap="xl" align="center" >
{numPages === 0 && (
<Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
)}
{dualPage
? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
<Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
<LazyPageImage
pageIndex={i * 2}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
{i * 2 + 1 < numPages && (
<LazyPageImage
pageIndex={i * 2 + 1}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
)}
</Group>
))
: Array.from({ length: numPages }).map((_, idx) => (
<LazyPageImage
key={idx}
pageIndex={idx}
zoom={zoom}
theme={theme}
isFirst={idx === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
))}
</Stack>
{/* Navigation bar overlays the scroll area */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
}}
>
<Paper
radius="xl xl 0 0"
shadow="sm"
p={12}
pb={12}
style={{
display: "flex",
alignItems: "center",
gap: 10,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
pointerEvents: "auto",
minWidth: 420,
maxWidth: 700,
flexWrap: "wrap",
}}
>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
scrollToPage(1);
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<FirstPageIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
const prevPage = Math.max(1, (currentPage || 1) - 1);
scrollToPage(prevPage);
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<ArrowBackIosNewIcon fontSize="small" />
</Button>
<NumberInput
value={currentPage || 1}
onChange={value => {
const page = Number(value);
if (!isNaN(page) && page >= 1 && page <= numPages) {
scrollToPage(page);
}
}}
min={1}
max={numPages}
hideControls
styles={{
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16},
}}
/>
<span style={{ fontWeight: 500, fontSize: 16 }}>
/ {numPages}
</span>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
const nextPage = Math.min(numPages, (currentPage || 1) + 1);
scrollToPage(nextPage);
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<ArrowForwardIosIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
scrollToPage(numPages);
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<LastPageIcon fontSize="small" />
</Button>
<Button
variant={dualPage ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={() => setDualPage(v => !v)}
style={{ minWidth: 36 }}
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setZoom(z => Math.max(0.1, z - 0.1))}
style={{ minWidth: 32, padding: 0 }}
title={t("viewer.zoomOut", "Zoom out")}
></Button>
<span style={{ minWidth: 40, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setZoom(z => Math.min(5, z + 0.1))}
style={{ minWidth: 32, padding: 0 }}
title={t("viewer.zoomIn", "Zoom in")}
>+</Button>
</Group>
</Paper>
</div>
</ScrollArea>
)}
</>
)}
</Box>
);
};
export default Viewer;

View File

@ -7,7 +7,6 @@ import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import LastPageIcon from '@mui/icons-material/LastPage';
import DescriptionIcon from '@mui/icons-material/Description';
import ViewWeekIcon from '@mui/icons-material/ViewWeek';
import '../../types/embedPdf';
interface PdfViewerToolbarProps {
// Page navigation props (placeholders for now)
@ -42,23 +41,23 @@ export function PdfViewerToolbar({
useEffect(() => {
const updateState = () => {
// Update zoom
if (window.embedPdfZoom) {
const zoomPercent = window.embedPdfZoom.zoomPercent || currentZoom;
if ((window as any).embedPdfZoom) {
const zoomPercent = (window as any).embedPdfZoom.zoomPercent || currentZoom;
setDynamicZoom(zoomPercent);
}
// Update scroll/page state
if (window.embedPdfScroll) {
const currentPageNum = window.embedPdfScroll.currentPage || currentPage;
const totalPagesNum = window.embedPdfScroll.totalPages || totalPages;
if ((window as any).embedPdfScroll) {
const currentPageNum = (window as any).embedPdfScroll.currentPage || currentPage;
const totalPagesNum = (window as any).embedPdfScroll.totalPages || totalPages;
setDynamicPage(currentPageNum);
setDynamicTotalPages(totalPagesNum);
setPageInput(currentPageNum);
}
// Update pan mode state
if (window.embedPdfPan) {
const panState = window.embedPdfPan.isPanning || false;
if ((window as any).embedPdfPan) {
const panState = (window as any).embedPdfPan.isPanning || false;
setIsPanning(panState);
}
};
@ -73,20 +72,20 @@ export function PdfViewerToolbar({
}, [currentZoom, currentPage, totalPages]);
const handleZoomOut = () => {
if (window.embedPdfZoom) {
window.embedPdfZoom.zoomOut();
if ((window as any).embedPdfZoom) {
(window as any).embedPdfZoom.zoomOut();
}
};
const handleZoomIn = () => {
if (window.embedPdfZoom) {
window.embedPdfZoom.zoomIn();
if ((window as any).embedPdfZoom) {
(window as any).embedPdfZoom.zoomIn();
}
};
const handlePageNavigation = (page: number) => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToPage(page);
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToPage(page);
} else if (onPageChange) {
onPageChange(page);
}
@ -94,32 +93,32 @@ export function PdfViewerToolbar({
};
const handleFirstPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToFirstPage();
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToFirstPage();
} else {
handlePageNavigation(1);
}
};
const handlePreviousPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToPreviousPage();
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToPreviousPage();
} else {
handlePageNavigation(Math.max(1, dynamicPage - 1));
}
};
const handleNextPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToNextPage();
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToNextPage();
} else {
handlePageNavigation(Math.min(dynamicTotalPages, dynamicPage + 1));
}
};
const handleLastPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToLastPage();
if ((window as any).embedPdfScroll) {
(window as any).embedPdfScroll.scrollToLastPage();
} else {
handlePageNavigation(dynamicTotalPages);
}

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Box, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
import { LocalIcon } from '../shared/LocalIcon';
import '../../types/embedPdf';
interface ThumbnailSidebarProps {
visible: boolean;
@ -19,7 +18,7 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
// Get total pages from scroll API
useEffect(() => {
const scrollAPI = window.embedPdfScroll;
const scrollAPI = (window as any).embedPdfScroll;
if (scrollAPI && scrollAPI.totalPages) {
setTotalPages(scrollAPI.totalPages);
}
@ -29,7 +28,7 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
useEffect(() => {
if (!visible || totalPages === 0) return;
const thumbnailAPI = window.embedPdfThumbnail?.thumbnailAPI;
const thumbnailAPI = (window as any).embedPdfThumbnail?.thumbnailAPI;
console.log('📄 ThumbnailSidebar useEffect triggered:', {
visible,
thumbnailAPI: !!thumbnailAPI,
@ -89,14 +88,14 @@ export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSi
}
});
};
}, [visible, totalPages]);
}, [visible, totalPages, thumbnails]);
const handlePageClick = (pageIndex: number) => {
const pageNumber = pageIndex + 1; // Convert to 1-based
setSelectedPage(pageNumber);
// Use scroll API to navigate to page
const scrollAPI = window.embedPdfScroll;
const scrollAPI = (window as any).embedPdfScroll;
if (scrollAPI && scrollAPI.scrollToPage) {
scrollAPI.scrollToPage(pageNumber);
}

View File

@ -1,46 +0,0 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface ViewerContextType {
// Thumbnail sidebar state
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
setThumbnailSidebarVisible: (visible: boolean) => void;
}
const ViewerContext = createContext<ViewerContextType | null>(null);
interface ViewerProviderProps {
children: ReactNode;
}
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const setThumbnailSidebarVisible = (visible: boolean) => {
setIsThumbnailSidebarVisible(visible);
};
const value: ViewerContextType = {
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
setThumbnailSidebarVisible,
};
return (
<ViewerContext.Provider value={value}>
{children}
</ViewerContext.Provider>
);
};
export const useViewer = (): ViewerContextType => {
const context = useContext(ViewerContext);
if (!context) {
throw new Error('useViewer must be used within a ViewerProvider');
}
return context;
};

View File

@ -145,17 +145,12 @@ 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,
}
};
}
@ -239,14 +234,11 @@ 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);
}

View File

@ -136,13 +136,13 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
/**
* Hook for selected files (optimized for selection-based UI)
*/
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
selectedFiles: selectors.getSelectedFiles(),
selectedRecords: selectors.getSelectedStirlingFileStubs(),
selectedFileIds: state.ui.selectedFileIds
files: selectors.getSelectedFiles(),
records: selectors.getSelectedStirlingFileStubs(),
fileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}
@ -169,6 +169,7 @@ 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 => {

View File

@ -11,7 +11,6 @@ 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";
@ -31,10 +30,8 @@ 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";
import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
@ -47,13 +44,7 @@ import OCRSettings from "../components/tools/ocr/OCRSettings";
import ConvertSettings from "../components/tools/convert/ConvertSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings';
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -340,14 +331,11 @@ export function useFlatToolRegistry(): ToolRegistry {
"adjust-page-size-scale": {
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.scalePages.title", "Adjust page size/scale"),
component: AdjustPageScale,
component: null,
description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["scale-pages"],
operationConfig: adjustPageScaleOperationConfig,
settingsComponent: AdjustPageScaleSettings,
},
addPageNumbers: {
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
@ -681,14 +669,12 @@ export function useFlatToolRegistry(): ToolRegistry {
mergePdfs: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.merge.title", "Merge"),
component: Merge,
component: null,
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: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
@ -715,14 +701,10 @@ export function useFlatToolRegistry(): ToolRegistry {
redact: {
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.redact.title", "Redact"),
component: Redact,
component: null,
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["auto-redact"],
operationConfig: redactOperationConfig,
settingsComponent: RedactSingleStepSettings,
},
};

View File

@ -1,30 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AdjustPageScaleParameters, defaultParameters } from './useAdjustPageScaleParameters';
export const buildAdjustPageScaleFormData = (parameters: AdjustPageScaleParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("scaleFactor", parameters.scaleFactor.toString());
formData.append("pageSize", parameters.pageSize);
return formData;
};
export const adjustPageScaleOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAdjustPageScaleFormData,
operationType: 'adjustPageScale',
endpoint: '/api/v1/general/scale-pages',
filePrefix: 'scaled_',
defaultParameters,
} as const;
export const useAdjustPageScaleOperation = () => {
const { t } = useTranslation();
return useToolOperation<AdjustPageScaleParameters>({
...adjustPageScaleOperationConfig,
getErrorMessage: createStandardErrorHandler(t('adjustPageScale.error.failed', 'An error occurred while adjusting the page scale.'))
});
};

View File

@ -1,142 +0,0 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from './useAdjustPageScaleParameters';
describe('useAdjustPageScaleParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
expect(result.current.parameters.scaleFactor).toBe(1.0);
expect(result.current.parameters.pageSize).toBe(PageSize.KEEP);
});
test.each([
{ paramName: 'scaleFactor' as const, value: 0.5 },
{ paramName: 'scaleFactor' as const, value: 2.0 },
{ paramName: 'scaleFactor' as const, value: 10.0 },
{ paramName: 'pageSize' as const, value: PageSize.A4 },
{ paramName: 'pageSize' as const, value: PageSize.LETTER },
{ paramName: 'pageSize' as const, value: PageSize.LEGAL },
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toBe(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
// First, change some parameters
act(() => {
result.current.updateParameter('scaleFactor', 2.5);
result.current.updateParameter('pageSize', PageSize.A3);
});
expect(result.current.parameters.scaleFactor).toBe(2.5);
expect(result.current.parameters.pageSize).toBe(PageSize.A3);
// Then reset
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should return correct endpoint name', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.getEndpointName()).toBe('scale-pages');
});
test.each([
{
description: 'with default parameters',
setup: () => {},
expected: true
},
{
description: 'with valid scale factor 0.1',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 0.1);
},
expected: true
},
{
description: 'with valid scale factor 10.0',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 10.0);
},
expected: true
},
{
description: 'with A4 page size',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('pageSize', PageSize.A4);
},
expected: true
},
{
description: 'with invalid scale factor 0',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', 0);
},
expected: false
},
{
description: 'with negative scale factor',
setup: (hook: AdjustPageScaleParametersHook) => {
hook.updateParameter('scaleFactor', -0.5);
},
expected: false
}
])('should validate parameters correctly $description', ({ setup, expected }) => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
setup(result.current);
});
expect(result.current.validateParameters()).toBe(expected);
});
test('should handle all PageSize enum values', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
Object.values(PageSize).forEach(pageSize => {
act(() => {
result.current.updateParameter('pageSize', pageSize);
});
expect(result.current.parameters.pageSize).toBe(pageSize);
expect(result.current.validateParameters()).toBe(true);
});
});
test('should handle scale factor edge cases', () => {
const { result } = renderHook(() => useAdjustPageScaleParameters());
// Test very small valid scale factor
act(() => {
result.current.updateParameter('scaleFactor', 0.01);
});
expect(result.current.validateParameters()).toBe(true);
// Test scale factor just above zero
act(() => {
result.current.updateParameter('scaleFactor', 0.001);
});
expect(result.current.validateParameters()).toBe(true);
// Test exactly zero (invalid)
act(() => {
result.current.updateParameter('scaleFactor', 0);
});
expect(result.current.validateParameters()).toBe(false);
});
});

View File

@ -1,37 +0,0 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export enum PageSize {
KEEP = 'KEEP',
A0 = 'A0',
A1 = 'A1',
A2 = 'A2',
A3 = 'A3',
A4 = 'A4',
A5 = 'A5',
A6 = 'A6',
LETTER = 'LETTER',
LEGAL = 'LEGAL'
}
export interface AdjustPageScaleParameters extends BaseParameters {
scaleFactor: number;
pageSize: PageSize;
}
export const defaultParameters: AdjustPageScaleParameters = {
scaleFactor: 1.0,
pageSize: PageSize.KEEP,
};
export type AdjustPageScaleParametersHook = BaseParametersHook<AdjustPageScaleParameters>;
export const useAdjustPageScaleParameters = (): AdjustPageScaleParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'scale-pages',
validateFn: (params) => {
return params.scaleFactor > 0;
},
});
};

View File

@ -1,138 +0,0 @@
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<MergeParameters>);
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig<MergeParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
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<void> {
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');
});
});

View File

@ -1,41 +0,0 @@
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<MergeParameters> = {
toolType: ToolType.multiFile,
buildFormData,
operationType: 'merge',
endpoint: '/api/v1/general/merge-pdfs',
filePrefix: 'merged_',
responseHandler: mergeResponseHandler,
};
export const useMergeOperation = () => {
const { t } = useTranslation();
return useToolOperation<MergeParameters>({
...mergeOperationConfig,
getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
});
};

View File

@ -1,68 +0,0 @@
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);
});
});

View File

@ -1,21 +0,0 @@
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<MergeParameters>;
export const useMergeParameters = (): MergeParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: "merge-pdfs",
});
};

View File

@ -1,142 +0,0 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { buildRedactFormData, redactOperationConfig, useRedactOperation } from './useRedactOperation';
import { defaultParameters, RedactParameters } from './useRedactParameters';
// Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => {
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
return {
...actual,
useToolOperation: vi.fn()
};
});
// Mock the translation hook
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
}));
// Mock the error handler utility
vi.mock('../../../utils/toolErrorHandler', () => ({
createStandardErrorHandler: vi.fn(() => vi.fn())
}));
describe('buildRedactFormData', () => {
const mockFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
test('should build form data for automatic mode', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: ['Confidential', 'Secret'],
useRegex: true,
wholeWordSearch: true,
redactColor: '#FF0000',
customPadding: 0.5,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('fileInput')).toBe(mockFile);
expect(formData.get('listOfText')).toBe('Confidential\nSecret');
expect(formData.get('useRegex')).toBe('true');
expect(formData.get('wholeWordSearch')).toBe('true');
expect(formData.get('redactColor')).toBe('FF0000'); // Hash should be removed
expect(formData.get('customPadding')).toBe('0.5');
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should handle empty words array', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: [],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('listOfText')).toBe('');
});
test('should join multiple words with newlines', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
wordsToRedact: ['Word1', 'Word2', 'Word3'],
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('listOfText')).toBe('Word1\nWord2\nWord3');
});
test.each([
{ description: 'remove hash from redact color', redactColor: '#123456', expected: '123456' },
{ description: 'handle redact color without hash', redactColor: 'ABCDEF', expected: 'ABCDEF' },
])('should $description', ({ redactColor, expected }) => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
redactColor,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('redactColor')).toBe(expected);
});
test('should convert boolean parameters to strings', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'automatic',
useRegex: false,
wholeWordSearch: true,
convertPDFToImage: false,
};
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('useRegex')).toBe('false');
expect(formData.get('wholeWordSearch')).toBe('true');
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should throw error for manual mode (not implemented)', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'manual',
};
expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented');
});
});
describe('useRedactOperation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('should call useToolOperation with correct configuration', async () => {
const { useToolOperation } = await import('../shared/useToolOperation');
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
expect(mockUseToolOperation).toHaveBeenCalledWith({
...redactOperationConfig,
getErrorMessage: expect.any(Function),
});
});
test('should provide error handler to useToolOperation', async () => {
const { useToolOperation } = await import('../shared/useToolOperation');
const mockUseToolOperation = vi.mocked(useToolOperation);
renderHook(() => useRedactOperation());
const callArgs = mockUseToolOperation.mock.calls[0][0];
expect(typeof callArgs.getErrorMessage).toBe('function');
});
});

View File

@ -1,51 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RedactParameters, defaultParameters } from './useRedactParameters';
// Static configuration that can be used by both the hook and automation executor
export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
if (parameters.mode === 'automatic') {
// Convert array to newline-separated string as expected by backend
formData.append("listOfText", parameters.wordsToRedact.join('\n'));
formData.append("useRegex", parameters.useRegex.toString());
formData.append("wholeWordSearch", parameters.wholeWordSearch.toString());
formData.append("redactColor", parameters.redactColor.replace('#', ''));
formData.append("customPadding", parameters.customPadding.toString());
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
} else {
// Manual mode parameters would go here when implemented
throw new Error('Manual redaction not yet implemented');
}
return formData;
};
// Static configuration object
export const redactOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRedactFormData,
operationType: 'redact',
endpoint: (parameters: RedactParameters) => {
if (parameters.mode === 'automatic') {
return '/api/v1/security/auto-redact';
} else {
// Manual redaction endpoint would go here when implemented
throw new Error('Manual redaction not yet implemented');
}
},
filePrefix: 'redacted_',
defaultParameters,
} as const;
export const useRedactOperation = () => {
const { t } = useTranslation();
return useToolOperation<RedactParameters>({
...redactOperationConfig,
getErrorMessage: createStandardErrorHandler(t('redact.error.failed', 'An error occurred while redacting the PDF.'))
});
};

View File

@ -1,134 +0,0 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRedactParameters, defaultParameters } from './useRedactParameters';
describe('useRedactParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderHook(() => useRedactParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test.each([
{ paramName: 'mode' as const, value: 'manual' as const },
{ paramName: 'wordsToRedact' as const, value: ['word1', 'word2'] },
{ paramName: 'useRegex' as const, value: true },
{ paramName: 'wholeWordSearch' as const, value: true },
{ paramName: 'redactColor' as const, value: '#FF0000' },
{ paramName: 'customPadding' as const, value: 0.5 },
{ paramName: 'convertPDFToImage' as const, value: false }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter(paramName, value);
});
expect(result.current.parameters[paramName]).toStrictEqual(value);
});
test('should reset parameters to defaults', () => {
const { result } = renderHook(() => useRedactParameters());
// Modify some parameters
act(() => {
result.current.updateParameter('mode', 'manual');
result.current.updateParameter('wordsToRedact', ['test']);
result.current.updateParameter('useRegex', true);
});
// Reset parameters
act(() => {
result.current.resetParameters();
});
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
describe('validation', () => {
test.each([
{ description: 'validate when wordsToRedact has non-empty words in automatic mode', wordsToRedact: ['word1', 'word2'], expected: true },
{ description: 'not validate when wordsToRedact is empty in automatic mode', wordsToRedact: [], expected: false },
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
])('should $description', ({ wordsToRedact, expected }) => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
result.current.updateParameter('wordsToRedact', wordsToRedact);
});
expect(result.current.validateParameters()).toBe(expected);
});
test('should not validate in manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(result.current.validateParameters()).toBe(false);
});
});
describe('endpoint handling', () => {
test('should return correct endpoint for automatic mode', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
});
expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
});
test('should throw error for manual mode (not implemented)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented');
});
});
test('should maintain parameter state across updates', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('redactColor', '#FF0000');
result.current.updateParameter('customPadding', 0.5);
result.current.updateParameter('wordsToRedact', ['word1']);
});
// All parameters should be updated
expect(result.current.parameters.redactColor).toBe('#FF0000');
expect(result.current.parameters.customPadding).toBe(0.5);
expect(result.current.parameters.wordsToRedact).toEqual(['word1']);
// Other parameters should remain at defaults
expect(result.current.parameters.mode).toBe('automatic');
expect(result.current.parameters.useRegex).toBe(false);
expect(result.current.parameters.wholeWordSearch).toBe(false);
expect(result.current.parameters.convertPDFToImage).toBe(true);
});
test('should handle array parameter updates correctly', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('wordsToRedact', ['initial']);
});
expect(result.current.parameters.wordsToRedact).toEqual(['initial']);
act(() => {
result.current.updateParameter('wordsToRedact', ['updated', 'multiple']);
});
expect(result.current.parameters.wordsToRedact).toEqual(['updated', 'multiple']);
});
});

View File

@ -1,48 +0,0 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export type RedactMode = 'automatic' | 'manual';
export interface RedactParameters extends BaseParameters {
mode: RedactMode;
// Automatic redaction parameters
wordsToRedact: string[];
useRegex: boolean;
wholeWordSearch: boolean;
redactColor: string;
customPadding: number;
convertPDFToImage: boolean;
}
export const defaultParameters: RedactParameters = {
mode: 'automatic',
wordsToRedact: [],
useRegex: false,
wholeWordSearch: false,
redactColor: '#000000',
customPadding: 0.1,
convertPDFToImage: true,
};
export type RedactParametersHook = BaseParametersHook<RedactParameters>;
export const useRedactParameters = (): RedactParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: (params) => {
if (params.mode === 'automatic') {
return '/api/v1/security/auto-redact';
}
// Manual redaction endpoint would go here when implemented
throw new Error('Manual redaction not yet implemented');
},
validateFn: (params) => {
if (params.mode === 'automatic') {
return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0);
}
// Manual mode validation would go here when implemented
return false;
}
});
};

View File

@ -6,12 +6,12 @@ import { ToolOperationHook } from './useToolOperation';
import { BaseParametersHook } from './useBaseParameters';
import { StirlingFile } from '../../../types/fileContext';
interface BaseToolReturn<TParams, TParamsHook extends BaseParametersHook<TParams>> {
interface BaseToolReturn<TParams> {
// File management
selectedFiles: StirlingFile[];
// Tool-specific hooks
params: TParamsHook;
params: BaseParametersHook<TParams>;
operation: ToolOperationHook<TParams>;
// Endpoint validation
@ -33,14 +33,12 @@ interface BaseToolReturn<TParams, TParamsHook extends BaseParametersHook<TParams
/**
* Base tool hook for tool components. Manages standard behaviour for tools.
*/
export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TParams>>(
export function useBaseTool<TParams>(
toolName: string,
useParams: () => TParamsHook,
useParams: () => BaseParametersHook<TParams>,
useOperation: () => ToolOperationHook<TParams>,
props: BaseToolProps,
options?: { minFiles?: number }
): BaseToolReturn<TParams, TParamsHook> {
const minFiles = options?.minFiles ?? 1;
): BaseToolReturn<TParams> {
const { onPreviewFile, onComplete, onError } = props;
// File selection
@ -98,7 +96,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
}, [operation, onPreviewFile]);
// Standard computed state
const hasFiles = selectedFiles.length >= minFiles;
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;

View File

@ -59,7 +59,6 @@ i18n
.init({
fallbackLng: 'en-GB',
supportedLngs: Object.keys(supportedLanguages),
load: 'currentOnly',
nonExplicitSupportedLngs: false,
debug: process.env.NODE_ENV === 'development',

View File

@ -1,58 +0,0 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
import { useAdjustPageScaleParameters } from "../hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
import { useAdjustPageScaleOperation } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useAdjustPageScaleTips } from "../components/tooltips/useAdjustPageScaleTips";
const AdjustPageScale = (props: BaseToolProps) => {
const { t } = useTranslation();
const adjustPageScaleTips = useAdjustPageScaleTips();
const base = useBaseTool(
'adjustPageScale',
useAdjustPageScaleParameters,
useAdjustPageScaleOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: adjustPageScaleTips,
content: (
<AdjustPageScaleSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("adjustPageScale.submit", "Adjust Page Scale"),
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("adjustPageScale.title", "Page Scale Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default AdjustPageScale as ToolComponent;

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigationActions } from "../contexts/NavigationContext";
@ -161,10 +161,25 @@ 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 = [

View File

@ -100,6 +100,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
files: {
selectedFiles,
isCollapsed: hasResults,
placeholder: t("convert.selectFilesPlaceholder", "Select files in the main view to get started"),
},
steps: [
{

View File

@ -22,6 +22,7 @@ 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: [
{
@ -58,4 +59,4 @@ const Flatten = (props: BaseToolProps) => {
// Static method to get the operation hook for automation
Flatten.tool = () => useFlattenOperation;
export default Flatten as ToolComponent;
export default Flatten as ToolComponent;

View File

@ -1,98 +0,0 @@
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: (
<MergeFileSorter
onSortFiles={sortFiles}
disabled={!base.hasFiles || base.endpointLoading}
/>
),
},
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: mergeTips,
content: (
<MergeSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
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;

View File

@ -1,120 +0,0 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import RedactModeSelector from "../components/tools/redact/RedactModeSelector";
import { useRedactParameters } from "../hooks/tools/redact/useRedactParameters";
import { useRedactOperation } from "../hooks/tools/redact/useRedactOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips } from "../components/tooltips/useRedactTips";
import RedactAdvancedSettings from "../components/tools/redact/RedactAdvancedSettings";
import WordsToRedactInput from "../components/tools/redact/WordsToRedactInput";
const Redact = (props: BaseToolProps) => {
const { t } = useTranslation();
// State for managing step collapse status
const [methodCollapsed, setMethodCollapsed] = useState(false);
const [wordsCollapsed, setWordsCollapsed] = useState(false);
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
const base = useBaseTool(
'redact',
useRedactParameters,
useRedactOperation,
props
);
// Tooltips for each step
const modeTips = useRedactModeTips();
const wordsTips = useRedactWordsTips();
const advancedTips = useRedactAdvancedTips();
const isExecuteDisabled = () => {
if (base.params.parameters.mode === 'manual') {
return true; // Manual mode not implemented yet
}
return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled;
};
// Compute actual collapsed state based on results and user state
const getActualCollapsedState = (userCollapsed: boolean) => {
return (!base.hasFiles || base.hasResults) ? true : userCollapsed; // Force collapse when results are shown
};
// Build conditional steps based on redaction mode
const buildSteps = () => {
const steps = [
// Method selection step (always present)
{
title: t("redact.modeSelector.title", "Redaction Method"),
isCollapsed: getActualCollapsedState(methodCollapsed),
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setMethodCollapsed(!methodCollapsed),
tooltip: modeTips,
content: (
<RedactModeSelector
mode={base.params.parameters.mode}
onModeChange={(mode) => base.params.updateParameter('mode', mode)}
disabled={base.endpointLoading}
/>
),
}
];
// Add mode-specific steps
if (base.params.parameters.mode === 'automatic') {
steps.push(
{
title: t("redact.auto.settings.title", "Redaction Settings"),
isCollapsed: getActualCollapsedState(wordsCollapsed),
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setWordsCollapsed(!wordsCollapsed),
tooltip: wordsTips,
content: <WordsToRedactInput
wordsToRedact={base.params.parameters.wordsToRedact}
onWordsChange={(words) => base.params.updateParameter('wordsToRedact', words)}
disabled={base.endpointLoading}
/>,
},
{
title: t("redact.auto.settings.advancedTitle", "Advanced Settings"),
isCollapsed: getActualCollapsedState(advancedCollapsed),
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setAdvancedCollapsed(!advancedCollapsed),
tooltip: advancedTips,
content: <RedactAdvancedSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>,
},
);
} else if (base.params.parameters.mode === 'manual') {
// Manual mode steps would go here when implemented
}
return steps;
};
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: buildSteps(),
executeButton: {
text: t("redact.submit", "Redact"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: isExecuteDisabled(),
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("redact.title", "Redaction Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default Redact as ToolComponent;

View File

@ -19,6 +19,7 @@ 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: {

View File

@ -19,6 +19,7 @@ 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: {

View File

@ -20,6 +20,7 @@ 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: [
{

View File

@ -19,6 +19,7 @@ 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: {

View File

@ -19,6 +19,7 @@ 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: {

View File

@ -1,43 +0,0 @@
// Types for EmbedPDF global APIs
export interface EmbedPdfZoomAPI {
zoomPercent: number;
zoomIn: () => void;
zoomOut: () => void;
}
export interface EmbedPdfScrollAPI {
currentPage: number;
totalPages: number;
scrollToPage: (page: number) => void;
scrollToFirstPage: () => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
scrollToLastPage: () => void;
}
export interface EmbedPdfPanAPI {
isPanning: boolean;
}
export interface EmbedPdfSpreadAPI {
toggleSpreadMode: () => void;
}
export interface EmbedPdfThumbnailAPI {
thumbnailAPI: {
renderThumb: (pageIndex: number, scale: number) => {
toPromise: () => Promise<Blob>;
};
};
}
declare global {
interface Window {
embedPdfZoom?: EmbedPdfZoomAPI;
embedPdfScroll?: EmbedPdfScrollAPI;
embedPdfPan?: EmbedPdfPanAPI;
embedPdfSpread?: EmbedPdfSpreadAPI;
embedPdfThumbnail?: EmbedPdfThumbnailAPI;
toggleThumbnailSidebar?: () => void;
}
}

View File

@ -31,6 +31,7 @@
"moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
// "@framework": ["vendor/embed-pdf-viewer/packages/core/src/react/adapter.ts"]
},
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */

View File

@ -1,74 +0,0 @@
%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
[<cb35d644a26f0c9be3597a7f8189b123><cb35d644a26f0c9be3597a7f8189b123>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1186
%%EOF

View File

@ -1,74 +0,0 @@
%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

View File

@ -1,74 +0,0 @@
%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""%'<u)dPtNs!.p_7Cem+LKojd:CaF,4$g:S_<`9sPL'Dq([aoCSX;_^WU4Wa'KgNd255,.iQh#\m&~>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

View File

@ -1,74 +0,0 @@
%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
[<ade40b97468692afaf20f74813f90619><ade40b97468692afaf20f74813f90619>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1186
%%EOF