From 458bb641b5d6a565bbeebd0f27a88577332fa8c2 Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Thu, 2 Oct 2025 17:48:39 +0100
Subject: [PATCH] Feature/adjust colors contrast tool (#4544)
# Description of Changes
- Addition of the "Adjust Colors/Contrast" tool
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
---
.../public/locales/en-GB/translation.json | 7 +-
.../public/locales/en-US/translation.json | 7 +-
.../sliderWithInput/SliderWithInput.tsx | 44 +++++++
.../AdjustContrastBasicSettings.tsx | 25 ++++
.../AdjustContrastColorSettings.tsx | 25 ++++
.../adjustContrast/AdjustContrastPreview.tsx | 93 ++++++++++++++
.../AdjustContrastSingleStepSettings.tsx | 31 +++++
.../components/tools/adjustContrast/utils.ts | 79 ++++++++++++
.../tools/shared/createToolFlow.tsx | 6 +
.../src/data/useTranslatedToolRegistry.tsx | 8 +-
.../useAdjustContrastOperation.ts | 94 ++++++++++++++
.../useAdjustContrastParameters.ts | 30 +++++
frontend/src/tools/AdjustContrast.tsx | 121 ++++++++++++++++++
13 files changed, 562 insertions(+), 8 deletions(-)
create mode 100644 frontend/src/components/shared/sliderWithInput/SliderWithInput.tsx
create mode 100644 frontend/src/components/tools/adjustContrast/AdjustContrastBasicSettings.tsx
create mode 100644 frontend/src/components/tools/adjustContrast/AdjustContrastColorSettings.tsx
create mode 100644 frontend/src/components/tools/adjustContrast/AdjustContrastPreview.tsx
create mode 100644 frontend/src/components/tools/adjustContrast/AdjustContrastSingleStepSettings.tsx
create mode 100644 frontend/src/components/tools/adjustContrast/utils.ts
create mode 100644 frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts
create mode 100644 frontend/src/hooks/tools/adjustContrast/useAdjustContrastParameters.ts
create mode 100644 frontend/src/tools/AdjustContrast.tsx
diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index b1646e76f..2772ad15e 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -547,7 +547,7 @@
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colours/Contrast",
- "desc": "Adjust Contrast, Saturation and Brightness of a PDF"
+ "desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
@@ -2791,8 +2791,9 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
- "title": "Adjust Contrast",
- "header": "Adjust Contrast",
+ "title": "Adjust Colors/Contrast",
+ "header": "Adjust Colors/Contrast",
+ "basic": "Basic Adjustments",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json
index d20cca6ae..b4ac8302d 100644
--- a/frontend/public/locales/en-US/translation.json
+++ b/frontend/public/locales/en-US/translation.json
@@ -562,7 +562,7 @@
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colors/Contrast",
- "desc": "Adjust Contrast, Saturation and Brightness of a PDF"
+ "desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
@@ -1712,8 +1712,9 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
- "title": "Adjust Contrast",
- "header": "Adjust Contrast",
+ "title": "Adjust Colors/Contrast",
+ "header": "Adjust Colors/Contrast",
+ "basic": "Basic Adjustments",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
diff --git a/frontend/src/components/shared/sliderWithInput/SliderWithInput.tsx b/frontend/src/components/shared/sliderWithInput/SliderWithInput.tsx
new file mode 100644
index 000000000..e8e504ded
--- /dev/null
+++ b/frontend/src/components/shared/sliderWithInput/SliderWithInput.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { Slider, Text, Group, NumberInput } from '@mantine/core';
+
+interface Props {
+ label: string;
+ value: number;
+ onChange: (value: number) => void;
+ disabled?: boolean;
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+export default function SliderWithInput({
+ label,
+ value,
+ onChange,
+ disabled,
+ min = 0,
+ max = 200,
+ step = 1,
+}: Props) {
+ return (
+
+
{label}: {Math.round(value)}%
+
+
+
+
+ onChange(Number(v) || 0)}
+ min={min}
+ max={max}
+ step={step}
+ disabled={disabled}
+ style={{ width: 90 }}
+ />
+
+
+ );
+}
+
+
diff --git a/frontend/src/components/tools/adjustContrast/AdjustContrastBasicSettings.tsx b/frontend/src/components/tools/adjustContrast/AdjustContrastBasicSettings.tsx
new file mode 100644
index 000000000..6993186bb
--- /dev/null
+++ b/frontend/src/components/tools/adjustContrast/AdjustContrastBasicSettings.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { Stack } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
+import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput';
+
+interface Props {
+ parameters: AdjustContrastParameters;
+ onParameterChange: (key: K, value: AdjustContrastParameters[K]) => void;
+ disabled?: boolean;
+}
+
+export default function AdjustContrastBasicSettings({ parameters, onParameterChange, disabled }: Props) {
+ const { t } = useTranslation();
+
+ return (
+
+ onParameterChange('contrast', v as any)} disabled={disabled} />
+ onParameterChange('brightness', v as any)} disabled={disabled} />
+ onParameterChange('saturation', v as any)} disabled={disabled} />
+
+ );
+}
+
+
diff --git a/frontend/src/components/tools/adjustContrast/AdjustContrastColorSettings.tsx b/frontend/src/components/tools/adjustContrast/AdjustContrastColorSettings.tsx
new file mode 100644
index 000000000..0f2722873
--- /dev/null
+++ b/frontend/src/components/tools/adjustContrast/AdjustContrastColorSettings.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { Stack } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
+import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput';
+
+interface Props {
+ parameters: AdjustContrastParameters;
+ onParameterChange: (key: K, value: AdjustContrastParameters[K]) => void;
+ disabled?: boolean;
+}
+
+export default function AdjustContrastColorSettings({ parameters, onParameterChange, disabled }: Props) {
+ const { t } = useTranslation();
+
+ return (
+
+ onParameterChange('red', v as any)} disabled={disabled} />
+ onParameterChange('green', v as any)} disabled={disabled} />
+ onParameterChange('blue', v as any)} disabled={disabled} />
+
+ );
+}
+
+
diff --git a/frontend/src/components/tools/adjustContrast/AdjustContrastPreview.tsx b/frontend/src/components/tools/adjustContrast/AdjustContrastPreview.tsx
new file mode 100644
index 000000000..a50fb98ca
--- /dev/null
+++ b/frontend/src/components/tools/adjustContrast/AdjustContrastPreview.tsx
@@ -0,0 +1,93 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
+import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
+import ObscuredOverlay from '../../shared/ObscuredOverlay';
+import { Text } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { applyAdjustmentsToCanvas } from './utils';
+
+interface Props {
+ file: File | null;
+ parameters: AdjustContrastParameters;
+}
+
+export default function AdjustContrastPreview({ file, parameters }: Props) {
+ const { t } = useTranslation();
+ const containerRef = useRef(null);
+ const canvasRef = useRef(null);
+ const [thumb, setThumb] = useState(null);
+ const { requestThumbnail } = useThumbnailGeneration();
+
+ useEffect(() => {
+ let active = true;
+ const load = async () => {
+ if (!file || file.type !== 'application/pdf') { setThumb(null); return; }
+ const id = `${file.name}:${file.size}:${file.lastModified}:page:1`;
+ const tUrl = await requestThumbnail(id, file, 1);
+ if (active) setThumb(tUrl || null);
+ };
+ load();
+ return () => { active = false; };
+ }, [file, requestThumbnail]);
+
+ useEffect(() => {
+ const revoked: string | null = null;
+ const render = async () => {
+ if (!thumb || !canvasRef.current) return;
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.src = thumb;
+ await new Promise((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject();
+ });
+
+ // Draw thumbnail to a source canvas
+ const src = document.createElement('canvas');
+ src.width = img.naturalWidth;
+ src.height = img.naturalHeight;
+ const sctx = src.getContext('2d');
+ if (!sctx) return;
+ sctx.drawImage(img, 0, 0);
+
+ // Apply accurate pixel adjustments
+ const adjusted = applyAdjustmentsToCanvas(src, parameters);
+
+ // Draw adjusted onto display canvas
+ const display = canvasRef.current;
+ display.width = adjusted.width;
+ display.height = adjusted.height;
+ const dctx = display.getContext('2d');
+ if (!dctx) return;
+ dctx.clearRect(0, 0, display.width, display.height);
+ dctx.drawImage(adjusted, 0, 0);
+ };
+ render();
+ return () => {
+ if (revoked) URL.revokeObjectURL(revoked);
+ };
+ }, [thumb, parameters]);
+
+ return (
+
+
+
+
{t('common.preview', 'Preview')}
+
+
+
{t('adjustContrast.noPreview', 'Select a PDF to preview')}}
+ borderRadius={6}
+ >
+
+ {thumb && (
+
+ )}
+
+
+
+ );
+}
+
+
diff --git a/frontend/src/components/tools/adjustContrast/AdjustContrastSingleStepSettings.tsx b/frontend/src/components/tools/adjustContrast/AdjustContrastSingleStepSettings.tsx
new file mode 100644
index 000000000..ce19b8012
--- /dev/null
+++ b/frontend/src/components/tools/adjustContrast/AdjustContrastSingleStepSettings.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Stack } from '@mantine/core';
+import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
+import AdjustContrastBasicSettings from './AdjustContrastBasicSettings';
+import AdjustContrastColorSettings from './AdjustContrastColorSettings';
+
+interface Props {
+ parameters: AdjustContrastParameters;
+ onParameterChange: (key: K, value: AdjustContrastParameters[K]) => void;
+ disabled?: boolean;
+}
+
+// Single-step settings used by Automate to configure Adjust Contrast in one panel
+export default function AdjustContrastSingleStepSettings({ parameters, onParameterChange, disabled }: Props) {
+ return (
+
+
+
+
+ );
+}
+
+
diff --git a/frontend/src/components/tools/adjustContrast/utils.ts b/frontend/src/components/tools/adjustContrast/utils.ts
new file mode 100644
index 000000000..16dfc199f
--- /dev/null
+++ b/frontend/src/components/tools/adjustContrast/utils.ts
@@ -0,0 +1,79 @@
+import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
+
+export function applyAdjustmentsToCanvas(src: HTMLCanvasElement, params: AdjustContrastParameters): HTMLCanvasElement {
+ const out = document.createElement('canvas');
+ out.width = src.width;
+ out.height = src.height;
+ const ctx = out.getContext('2d');
+ if (!ctx) return src;
+ ctx.drawImage(src, 0, 0);
+
+ const imageData = ctx.getImageData(0, 0, out.width, out.height);
+ const data = imageData.data;
+
+ const contrast = params.contrast / 100; // 0..2
+ const brightness = params.brightness / 100; // 0..2
+ const saturation = params.saturation / 100; // 0..2
+ const redMul = params.red / 100; // 0..2
+ const greenMul = params.green / 100; // 0..2
+ const blueMul = params.blue / 100; // 0..2
+
+ const clamp = (v: number) => Math.min(255, Math.max(0, v));
+
+ for (let i = 0; i < data.length; i += 4) {
+ let r = data[i] * redMul;
+ let g = data[i + 1] * greenMul;
+ let b = data[i + 2] * blueMul;
+
+ // Contrast (centered at 128)
+ r = clamp((r - 128) * contrast + 128);
+ g = clamp((g - 128) * contrast + 128);
+ b = clamp((b - 128) * contrast + 128);
+
+ // Brightness
+ r = clamp(r * brightness);
+ g = clamp(g * brightness);
+ b = clamp(b * brightness);
+
+ // Saturation via HSL
+ const rn = r / 255, gn = g / 255, bn = b / 255;
+ const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn);
+ let h = 0, s = 0;
+ const l = (max + min) / 2;
+ if (max !== min) {
+ const d = max - min;
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ switch (max) {
+ case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
+ case gn: h = (bn - rn) / d + 2; break;
+ default: h = (rn - gn) / d + 4; break;
+ }
+ h /= 6;
+ }
+ s = Math.min(1, Math.max(0, s * saturation));
+ const hue2rgb = (p: number, q: number, t: number) => {
+ if (t < 0) t += 1; if (t > 1) t -= 1;
+ if (t < 1/6) return p + (q - p) * 6 * t;
+ if (t < 1/2) return q;
+ if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
+ return p;
+ };
+ let r2: number, g2: number, b2: number;
+ if (s === 0) { r2 = g2 = b2 = l; }
+ else {
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+ r2 = hue2rgb(p, q, h + 1/3);
+ g2 = hue2rgb(p, q, h);
+ b2 = hue2rgb(p, q, h - 1/3);
+ }
+ data[i] = clamp(Math.round(r2 * 255));
+ data[i + 1] = clamp(Math.round(g2 * 255));
+ data[i + 2] = clamp(Math.round(b2 * 255));
+ }
+
+ ctx.putImageData(imageData, 0, 0);
+ return out;
+}
+
+
diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx
index 4724648c8..ffa4b0db6 100644
--- a/frontend/src/components/tools/shared/createToolFlow.tsx
+++ b/frontend/src/components/tools/shared/createToolFlow.tsx
@@ -54,6 +54,8 @@ export interface ToolFlowConfig {
title?: TitleConfig;
files: FilesStepConfig;
steps: MiddleStepConfig[];
+ // Optional preview content rendered between steps and the execute button
+ preview?: React.ReactNode;
executeButton?: ExecuteButtonConfig;
review: ReviewStepConfig;
forceStepNumbers?: boolean;
@@ -90,6 +92,10 @@ export function createToolFlow(config: ToolFlowConfig) {
}, stepConfig.content)
)}
+ {/* Preview (outside steps, above execute button).
+ Hide when review is visible or when no files are selected. */}
+ {!config.review.isVisible && (config.files.selectedFiles?.length ?? 0) > 0 && config.preview}
+
{/* Execute Button */}
{config.executeButton && config.executeButton.isVisible !== false && (
,
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
- component: null,
+ component: AdjustContrast,
description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
+ operationConfig: adjustContrastOperationConfig,
+ automationSettings: AdjustContrastSingleStepSettings,
synonyms: getSynonyms(t, "adjustContrast"),
- automationSettings: null,
},
repair: {
icon: ,
diff --git a/frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts
new file mode 100644
index 000000000..c52ed9864
--- /dev/null
+++ b/frontend/src/hooks/tools/adjustContrast/useAdjustContrastOperation.ts
@@ -0,0 +1,94 @@
+import { useTranslation } from 'react-i18next';
+import { ToolType, useToolOperation } from '../shared/useToolOperation';
+import { AdjustContrastParameters, defaultParameters } from './useAdjustContrastParameters';
+import { PDFDocument as PDFLibDocument } from 'pdf-lib';
+import { applyAdjustmentsToCanvas } from '../../../components/tools/adjustContrast/utils';
+import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
+import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
+
+async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise {
+ const page = await pdf.getPage(pageNumber);
+ const viewport = page.getViewport({ scale });
+ const canvas = document.createElement('canvas');
+ canvas.width = viewport.width;
+ canvas.height = viewport.height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Canvas 2D context unavailable');
+ await page.render({ canvasContext: ctx, viewport }).promise;
+ return canvas;
+}
+
+// adjustment logic moved to shared util
+
+// Render, adjust, and assemble all pages of a single PDF into a new PDF
+async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise {
+ const arrayBuffer = await file.arrayBuffer();
+ const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
+ const pageCount = pdf.numPages;
+
+ const newDoc = await PDFLibDocument.create();
+
+ for (let p = 1; p <= pageCount; p++) {
+ const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
+ const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
+ const pngUrl = adjusted.toDataURL('image/png');
+ const res = await fetch(pngUrl);
+ const pngBytes = new Uint8Array(await res.arrayBuffer());
+ const embedded = await newDoc.embedPng(pngBytes);
+ const { width, height } = embedded.scale(1);
+ const page = newDoc.addPage([width, height]);
+ page.drawImage(embedded, { x: 0, y: 0, width, height });
+ }
+
+ const pdfBytes = await newDoc.save();
+ const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name);
+ pdfWorkerManager.destroyDocument(pdf);
+ return out;
+}
+
+async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise {
+ // Limit concurrency to avoid exhausting memory/CPU while still getting speedups
+ // Heuristic: use up to 4 workers on capable machines, otherwise 2-3
+ let CONCURRENCY_LIMIT = 2;
+ if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') {
+ if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
+ else if (navigator.hardwareConcurrency >= 4) CONCURRENCY_LIMIT = 3;
+ }
+ CONCURRENCY_LIMIT = Math.min(CONCURRENCY_LIMIT, files.length);
+
+ const mapWithConcurrency = async (items: T[], limit: number, worker: (item: T, index: number) => Promise): Promise => {
+ const results: R[] = new Array(items.length);
+ let nextIndex = 0;
+
+ const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
+ let current = nextIndex++;
+ while (current < items.length) {
+ results[current] = await worker(items[current], current);
+ current = nextIndex++;
+ }
+ });
+
+ await Promise.all(workers);
+ return results;
+ };
+
+ return mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params));
+}
+
+export const adjustContrastOperationConfig = {
+ toolType: ToolType.custom,
+ customProcessor: processPdfClientSide,
+ operationType: 'adjustContrast',
+ defaultParameters,
+ // Single-step settings component for Automate
+ settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings',
+} as const;
+
+export const useAdjustContrastOperation = () => {
+ const { t } = useTranslation();
+ return useToolOperation({
+ ...adjustContrastOperationConfig,
+ getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast')
+ });
+};
+
diff --git a/frontend/src/hooks/tools/adjustContrast/useAdjustContrastParameters.ts b/frontend/src/hooks/tools/adjustContrast/useAdjustContrastParameters.ts
new file mode 100644
index 000000000..14dd543d8
--- /dev/null
+++ b/frontend/src/hooks/tools/adjustContrast/useAdjustContrastParameters.ts
@@ -0,0 +1,30 @@
+import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
+
+export interface AdjustContrastParameters {
+ contrast: number; // 0-200 (%), 100 = neutral
+ brightness: number; // 0-200 (%), 100 = neutral
+ saturation: number; // 0-200 (%), 100 = neutral
+ red: number; // 0-200 (%), 100 = neutral
+ green: number; // 0-200 (%), 100 = neutral
+ blue: number; // 0-200 (%), 100 = neutral
+}
+
+export const defaultParameters: AdjustContrastParameters = {
+ contrast: 100,
+ brightness: 100,
+ saturation: 100,
+ red: 100,
+ green: 100,
+ blue: 100,
+};
+
+export type AdjustContrastParametersHook = BaseParametersHook;
+
+export const useAdjustContrastParameters = (): AdjustContrastParametersHook => {
+ return useBaseParameters({
+ defaultParameters,
+ endpointName: '',
+ });
+};
+
+
diff --git a/frontend/src/tools/AdjustContrast.tsx b/frontend/src/tools/AdjustContrast.tsx
new file mode 100644
index 000000000..0de528a40
--- /dev/null
+++ b/frontend/src/tools/AdjustContrast.tsx
@@ -0,0 +1,121 @@
+import { useTranslation } from 'react-i18next';
+import React, { useEffect, useMemo, useState } from 'react';
+import { createToolFlow } from '../components/tools/shared/createToolFlow';
+import { BaseToolProps, ToolComponent } from '../types/tool';
+import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
+import { useAdjustContrastParameters } from '../hooks/tools/adjustContrast/useAdjustContrastParameters';
+import { useAdjustContrastOperation } from '../hooks/tools/adjustContrast/useAdjustContrastOperation';
+import AdjustContrastBasicSettings from '../components/tools/adjustContrast/AdjustContrastBasicSettings';
+import AdjustContrastColorSettings from '../components/tools/adjustContrast/AdjustContrastColorSettings';
+import AdjustContrastPreview from '../components/tools/adjustContrast/AdjustContrastPreview';
+import { useAccordionSteps } from '../hooks/tools/shared/useAccordionSteps';
+import NavigationArrows from '../components/shared/filePreview/NavigationArrows';
+
+const AdjustContrast = (props: BaseToolProps) => {
+ const { t } = useTranslation();
+
+ const base = useBaseTool(
+ 'adjustContrast',
+ useAdjustContrastParameters,
+ useAdjustContrastOperation,
+ props
+ );
+
+ enum Step { NONE='none', BASIC='basic', COLORS='colors' }
+ const accordion = useAccordionSteps({
+ noneValue: Step.NONE,
+ initialStep: Step.BASIC,
+ stateConditions: { hasFiles: base.hasFiles, hasResults: base.hasResults },
+ afterResults: base.handleSettingsReset
+ });
+
+ // Track which selected file is being previewed. Clamp when selection changes.
+ const [previewIndex, setPreviewIndex] = useState(0);
+ const totalSelected = base.selectedFiles.length;
+
+ useEffect(() => {
+ if (previewIndex >= totalSelected) {
+ setPreviewIndex(Math.max(0, totalSelected - 1));
+ }
+ }, [totalSelected, previewIndex]);
+
+ const currentFile = useMemo(() => {
+ return totalSelected > 0 ? base.selectedFiles[previewIndex] : null;
+ }, [base.selectedFiles, previewIndex, totalSelected]);
+
+ const handlePrev = () => setPreviewIndex((i) => Math.max(0, i - 1));
+ const handleNext = () => setPreviewIndex((i) => Math.min(totalSelected - 1, i + 1));
+
+ return createToolFlow({
+ files: {
+ selectedFiles: base.selectedFiles,
+ isCollapsed: base.hasResults,
+ },
+ steps: [
+ {
+ title: t('adjustContrast.basic', 'Basic Adjustments'),
+ isCollapsed: accordion.getCollapsedState(Step.BASIC),
+ onCollapsedClick: () => accordion.handleStepToggle(Step.BASIC),
+ content: (
+
+ ),
+ },
+ {
+ title: t('adjustContrast.adjustColors', 'Adjust Colors'),
+ isCollapsed: accordion.getCollapsedState(Step.COLORS),
+ onCollapsedClick: () => accordion.handleStepToggle(Step.COLORS),
+ content: (
+
+ ),
+ },
+ ],
+ preview: (
+
+
+
+
+ {totalSelected > 1 && (
+
+ {`${previewIndex + 1} of ${totalSelected}`}
+
+ )}
+
+ ),
+ executeButton: {
+ text: t('adjustContrast.confirm', 'Confirm'),
+ isVisible: !base.hasResults,
+ loadingText: t('loading'),
+ onClick: base.handleExecute,
+ disabled: !base.hasFiles,
+ },
+ review: {
+ isVisible: base.hasResults,
+ operation: base.operation,
+ title: t('adjustContrast.results.title', 'Adjusted PDF'),
+ onFileClick: base.handleThumbnailClick,
+ onUndo: base.handleUndo,
+ },
+ forceStepNumbers: true,
+ });
+};
+
+export default AdjustContrast as ToolComponent;
+
+