mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
add colors to adjust colors contrast
This commit is contained in:
parent
d613a4659e
commit
fd3741e187
@ -507,7 +507,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",
|
||||
@ -2633,8 +2633,8 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
|
||||
@ -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",
|
||||
@ -1683,8 +1683,8 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [thumb, setThumb] = useState<string | null>(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(() => {
|
||||
let 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<void>((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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-color-muted)' }}>{t('common.preview', 'Preview')}</div>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
|
||||
</div>
|
||||
<ObscuredOverlay
|
||||
obscured={!thumb}
|
||||
overlayMessage={<Text size="sm" c="white" fw={600}>{t('adjustContrast.noPreview', 'Select a PDF to preview')}</Text>}
|
||||
borderRadius={6}
|
||||
>
|
||||
<div ref={containerRef} style={{ aspectRatio: '8.5/11', width: '100%', border: '1px solid var(--border-color)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
{thumb && (
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
</div>
|
||||
</ObscuredOverlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Stack, Slider, Text, Group, NumberInput, Divider } from '@mantine/core';
|
||||
import AdjustContrastPreview from './AdjustContrastPreview';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
|
||||
interface Props {
|
||||
parameters: AdjustContrastParameters;
|
||||
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
file?: File | null;
|
||||
}
|
||||
|
||||
export default function AdjustContrastSettings({ parameters, onParameterChange, disabled, file }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderSlider = (label: string, value: number, onChange: (v: number) => void) => (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>{label}: {Math.round(value)}%</Text>
|
||||
<Group gap="sm" align="center">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Slider min={0} max={200} step={1} value={value} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={(v) => onChange(Number(v) || 0)}
|
||||
min={0}
|
||||
max={200}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{renderSlider(t('adjustContrast.contrast', 'Contrast'), parameters.contrast, (v) => onParameterChange('contrast', v as any))}
|
||||
{renderSlider(t('adjustContrast.brightness', 'Brightness'), parameters.brightness, (v) => onParameterChange('brightness', v as any))}
|
||||
{renderSlider(t('adjustContrast.saturation', 'Saturation'), parameters.saturation, (v) => onParameterChange('saturation', v as any))}
|
||||
|
||||
<Divider />
|
||||
<Text size="sm" fw={700}>{t('adjustContrast.adjustColors', 'Adjust Colors')}</Text>
|
||||
{renderSlider(t('adjustContrast.red', 'Red'), parameters.red, (v) => onParameterChange('red', v as any))}
|
||||
{renderSlider(t('adjustContrast.green', 'Green'), parameters.green, (v) => onParameterChange('green', v as any))}
|
||||
{renderSlider(t('adjustContrast.blue', 'Blue'), parameters.blue, (v) => onParameterChange('blue', v as any))}
|
||||
{/* Inline accurate preview */}
|
||||
<AdjustContrastPreview file={file || null} parameters={parameters} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
78
frontend/src/components/tools/adjustContrast/utils.ts
Normal file
78
frontend/src/components/tools/adjustContrast/utils.ts
Normal file
@ -0,0 +1,78 @@
|
||||
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, 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;
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ import RemoveBlanks from "../tools/RemoveBlanks";
|
||||
import RemovePages from "../tools/RemovePages";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import AdjustContrast from "../tools/AdjustContrast";
|
||||
import { adjustContrastOperationConfig } from "../hooks/tools/adjustContrast/useAdjustContrastOperation";
|
||||
import { getSynonyms } from "../utils/toolSynonyms";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import AddStamp from "../tools/AddStamp";
|
||||
@ -74,6 +76,7 @@ import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/u
|
||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
import CropSettings from "../components/tools/crop/CropSettings";
|
||||
import AdjustContrastSettings from "../components/tools/adjustContrast/AdjustContrastSettings";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
|
||||
@ -602,10 +605,12 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
adjustContrast: {
|
||||
icon: <LocalIcon icon="palette" width="1.5rem" height="1.5rem" />,
|
||||
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,
|
||||
settingsComponent: AdjustContrastSettings,
|
||||
synonyms: getSynonyms(t, "adjustContrast"),
|
||||
},
|
||||
repair: {
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
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 { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||
|
||||
async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise<HTMLCanvasElement> {
|
||||
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
|
||||
|
||||
async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise<File[]> {
|
||||
const outputs: File[] = [];
|
||||
const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager');
|
||||
|
||||
for (const file of files) {
|
||||
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);
|
||||
outputs.push(out);
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
}
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
export const adjustContrastOperationConfig = {
|
||||
toolType: ToolType.custom,
|
||||
customProcessor: processPdfClientSide,
|
||||
operationType: 'adjustContrast',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useAdjustContrastOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
return useToolOperation<AdjustContrastParameters>({
|
||||
...adjustContrastOperationConfig,
|
||||
getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast')
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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<AdjustContrastParameters>;
|
||||
|
||||
export const useAdjustContrastParameters = (): AdjustContrastParametersHook => {
|
||||
return useBaseParameters<AdjustContrastParameters>({
|
||||
defaultParameters,
|
||||
endpointName: '',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
68
frontend/src/tools/AdjustContrast.tsx
Normal file
68
frontend/src/tools/AdjustContrast.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 AdjustContrastSettings from '../components/tools/adjustContrast/AdjustContrastSettings';
|
||||
import { useAccordionSteps } from '../hooks/tools/shared/useAccordionSteps';
|
||||
|
||||
const AdjustContrast = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
'adjustContrast',
|
||||
useAdjustContrastParameters,
|
||||
useAdjustContrastOperation,
|
||||
props
|
||||
);
|
||||
|
||||
enum Step { NONE='none', SETTINGS='settings' }
|
||||
const accordion = useAccordionSteps<Step>({
|
||||
noneValue: Step.NONE,
|
||||
initialStep: Step.SETTINGS,
|
||||
stateConditions: { hasFiles: base.hasFiles, hasResults: base.hasResults },
|
||||
afterResults: base.handleSettingsReset
|
||||
});
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t('adjustContrast.title', 'Adjust Colors/Contrast'),
|
||||
isCollapsed: accordion.getCollapsedState(Step.SETTINGS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(Step.SETTINGS),
|
||||
content: (
|
||||
<AdjustContrastSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
file={base.selectedFiles[0] || null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user