mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-26 17:52:59 +02:00
addition of Fake Scan tool
This commit is contained in:
parent
fd52dc0226
commit
2119ae5d8b
@ -582,7 +582,8 @@
|
|||||||
"fakeScan": {
|
"fakeScan": {
|
||||||
"tags": "scan,simulate,create",
|
"tags": "scan,simulate,create",
|
||||||
"title": "Fake Scan",
|
"title": "Fake Scan",
|
||||||
"desc": "Create a PDF that looks like it was scanned"
|
"desc": "Create a PDF that looks like it was scanned",
|
||||||
|
"noPreview": "No preview available, select a PDF to preview fake scan"
|
||||||
},
|
},
|
||||||
"editTableOfContents": {
|
"editTableOfContents": {
|
||||||
"tags": "bookmarks,contents,edit",
|
"tags": "bookmarks,contents,edit",
|
||||||
|
@ -671,6 +671,12 @@
|
|||||||
"title": "API Documentation",
|
"title": "API Documentation",
|
||||||
"desc": "View API documentation and test endpoints"
|
"desc": "View API documentation and test endpoints"
|
||||||
},
|
},
|
||||||
|
"fakeScan": {
|
||||||
|
"tags": "scan,simulate,create",
|
||||||
|
"title": "Fake Scan",
|
||||||
|
"desc": "Create a PDF that looks like it was scanned",
|
||||||
|
"noPreview": "No preview available, select a PDF to preview fake scan"
|
||||||
|
},
|
||||||
"replace-color": {
|
"replace-color": {
|
||||||
"tags": "color,replace,invert",
|
"tags": "color,replace,invert",
|
||||||
"title": "Replace and Invert Color",
|
"title": "Replace and Invert Color",
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Group, NumberInput, Slider, Stack, Switch } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FakeScanParameters } from '../../../hooks/tools/fakeScan/useFakeScanParameters';
|
||||||
|
import { useAdjustFontSizeToFit } from '../../shared/fitText/textFit';
|
||||||
|
// No basic option imports here; this panel focuses on advanced sliders only
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
parameters: FakeScanParameters;
|
||||||
|
onParameterChange: <K extends keyof FakeScanParameters>(key: K, value: FakeScanParameters[K]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FitLabel = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useAdjustFontSizeToFit(ref, { maxLines: 2 });
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ lineHeight: 1.15, minHeight: '2.3em', display: 'block' }}>{children}</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FakeScanAdvancedPanel({ parameters, onParameterChange, disabled }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const setAdvanced = () => {
|
||||||
|
if (!parameters.advancedEnabled) onParameterChange('advancedEnabled', true as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group grow>
|
||||||
|
<div>
|
||||||
|
<FitLabel>{t('scannerEffect.brightness', 'Brightness')}</FitLabel>
|
||||||
|
<Slider min={0.5} max={1.5} step={0.01} value={parameters.brightness} onChange={(v) => { setAdvanced(); onParameterChange('brightness', v as any); }} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FitLabel>{t('scannerEffect.contrast', 'Contrast')}</FitLabel>
|
||||||
|
<Slider min={0.5} max={1.5} step={0.01} value={parameters.contrast} onChange={(v) => { setAdvanced(); onParameterChange('contrast', v as any); }} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<div>
|
||||||
|
<FitLabel>{t('scannerEffect.rotation', 'Rotation')}</FitLabel>
|
||||||
|
<Slider min={-10} max={10} step={1} value={parameters.rotate} onChange={(v) => { setAdvanced(); onParameterChange('rotate', v as any); onParameterChange('rotateVariance', 0 as any); }} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FitLabel>{t('scannerEffect.blur', 'Blur')}</FitLabel>
|
||||||
|
<Slider min={0} max={5} step={0.1} value={parameters.blur} onChange={(v) => { setAdvanced(); onParameterChange('blur', v as any); }} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<div>
|
||||||
|
<FitLabel>{t('scannerEffect.noise', 'Noise')}</FitLabel>
|
||||||
|
<Slider min={0} max={10} step={0.1} value={parameters.noise} onChange={(v) => { setAdvanced(); onParameterChange('noise', v as any); }} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<FitLabel>{t('scannerEffect.yellowish', 'Yellowish (simulate old paper)')}</FitLabel>
|
||||||
|
<div style={{ marginTop: '8px' }}/>
|
||||||
|
<Switch checked={parameters.yellowish} onChange={(e) => { setAdvanced(); onParameterChange('yellowish', e.currentTarget.checked as any); }} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<div>
|
||||||
|
<FitLabel>{t('scannerEffect.resolution', 'Resolution (DPI)')}</FitLabel>
|
||||||
|
<NumberInput value={parameters.resolution} onChange={(v) => { setAdvanced(); onParameterChange('resolution', Number(v) || 72 as any); }} disabled={disabled} min={72} step={1} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Stack, Select, NumberInput } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FakeScanParameters } from '../../../hooks/tools/fakeScan/useFakeScanParameters';
|
||||||
|
import { getQualityOptions, getRotationOptions, getColorspaceOptions } from './constants';
|
||||||
|
import { useAdjustFontSizeToFit } from '../../shared/fitText/textFit';
|
||||||
|
|
||||||
|
const FitLabel = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useAdjustFontSizeToFit(ref, { maxLines: 2 });
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ lineHeight: 1.15, minHeight: '2.3em', display: 'block' }}>{children}</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FakeScanBasicSettings({
|
||||||
|
parameters,
|
||||||
|
onParameterChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
parameters: FakeScanParameters;
|
||||||
|
onParameterChange: <K extends keyof FakeScanParameters>(
|
||||||
|
key: K,
|
||||||
|
value: FakeScanParameters[K]
|
||||||
|
) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Select
|
||||||
|
label={<FitLabel>{t('scannerEffect.quality', 'Scan Quality')}</FitLabel>}
|
||||||
|
data={getQualityOptions(t)}
|
||||||
|
value={parameters.quality}
|
||||||
|
onChange={(v) => onParameterChange('quality', (v as any) || 'high')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<FitLabel>{t('scannerEffect.rotation', 'Rotation Angle')}</FitLabel>}
|
||||||
|
data={getRotationOptions(t)}
|
||||||
|
value={parameters.rotation}
|
||||||
|
onChange={(v) => onParameterChange('rotation', (v as any) || 'slight')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<FitLabel>{t('scannerEffect.colorspace', 'Colorspace')}</FitLabel>}
|
||||||
|
data={getColorspaceOptions(t)}
|
||||||
|
value={parameters.colorspace}
|
||||||
|
onChange={(v) => onParameterChange('colorspace', (v as any) || 'grayscale')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.border', 'Border (px)')}</FitLabel>}
|
||||||
|
value={parameters.border}
|
||||||
|
onChange={(v) => onParameterChange('border', Number(v) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
150
frontend/src/components/tools/fakeScan/FakeScanPreview.tsx
Normal file
150
frontend/src/components/tools/fakeScan/FakeScanPreview.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { FakeScanParameters } from '../../../hooks/tools/fakeScan/useFakeScanParameters';
|
||||||
|
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
|
||||||
|
import ObscuredOverlay from '../../shared/ObscuredOverlay';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
file?: File | null;
|
||||||
|
parameters: FakeScanParameters;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FakeScanPreview({ file, parameters }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [pageThumbnail, setPageThumbnail] = useState<string | null>(null);
|
||||||
|
const { requestThumbnail } = useThumbnailGeneration();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const load = async () => {
|
||||||
|
if (!file || file.type !== 'application/pdf') {
|
||||||
|
setPageThumbnail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pageId = `${file.name}:${file.size}:${file.lastModified}:page:1`;
|
||||||
|
const thumb = await requestThumbnail(pageId, file, 1);
|
||||||
|
if (active) setPageThumbnail(thumb || null);
|
||||||
|
} catch {
|
||||||
|
if (active) setPageThumbnail(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [file, requestThumbnail]);
|
||||||
|
|
||||||
|
const cssFilter = useMemo(() => {
|
||||||
|
// Apply basic quality preset if advanced settings are not enabled
|
||||||
|
let brightness = parameters.brightness;
|
||||||
|
let contrast = parameters.contrast;
|
||||||
|
let blur = Math.max(0, parameters.blur);
|
||||||
|
|
||||||
|
if (!parameters.advancedEnabled) {
|
||||||
|
// Apply quality presets
|
||||||
|
switch (parameters.quality) {
|
||||||
|
case 'high':
|
||||||
|
brightness = 1.02;
|
||||||
|
contrast = 1.05;
|
||||||
|
blur = 0.1;
|
||||||
|
break;
|
||||||
|
case 'medium':
|
||||||
|
brightness = 1.05;
|
||||||
|
contrast = 1.1;
|
||||||
|
blur = 0.5;
|
||||||
|
break;
|
||||||
|
case 'low':
|
||||||
|
brightness = 1.1;
|
||||||
|
contrast = 1.2;
|
||||||
|
blur = 1.0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grayscale = parameters.colorspace === 'grayscale' ? 'grayscale(1)' : 'grayscale(0)';
|
||||||
|
const sepia = parameters.yellowish ? 'sepia(0.35)' : 'sepia(0)';
|
||||||
|
// Simulate noise via drop-shadow stacking is heavy; skip and rely on server-side
|
||||||
|
return `${grayscale} ${sepia} brightness(${brightness}) contrast(${contrast}) blur(${blur}px)`;
|
||||||
|
}, [parameters]);
|
||||||
|
|
||||||
|
const rotation = useMemo(() => {
|
||||||
|
let base = parameters.rotate;
|
||||||
|
|
||||||
|
// Apply basic rotation preset if advanced settings are not enabled
|
||||||
|
if (!parameters.advancedEnabled) {
|
||||||
|
switch (parameters.rotation) {
|
||||||
|
case 'slight':
|
||||||
|
base += 2;
|
||||||
|
break;
|
||||||
|
case 'moderate':
|
||||||
|
base += 5;
|
||||||
|
break;
|
||||||
|
case 'severe':
|
||||||
|
base += 8;
|
||||||
|
break;
|
||||||
|
case 'none':
|
||||||
|
default:
|
||||||
|
// No additional rotation
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const variance = parameters.rotateVariance;
|
||||||
|
// Show deterministic max variance for preview
|
||||||
|
return base + variance;
|
||||||
|
}, [parameters.rotate, parameters.rotateVariance, parameters.rotation, parameters.advancedEnabled]);
|
||||||
|
|
||||||
|
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)' }}>Preview (approximate)</div>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
|
||||||
|
</div>
|
||||||
|
<ObscuredOverlay
|
||||||
|
obscured={!pageThumbnail}
|
||||||
|
overlayMessage={(
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-color-muted)' }}>{t("fakeScan.noPreview", "No preview available, select a PDF to preview fake scan")}</div>
|
||||||
|
)}
|
||||||
|
borderRadius={4}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--surface-1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
aspectRatio: '8.5/11',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageThumbnail && (
|
||||||
|
<img
|
||||||
|
src={pageThumbnail}
|
||||||
|
alt="page preview"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
filter: cssFilter,
|
||||||
|
transform: `rotate(${rotation}deg)`
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!pageThumbnail && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
|
||||||
|
color: 'var(--text-color-muted)', fontSize: 12
|
||||||
|
}}>
|
||||||
|
{t("fakeScan.noPreview", "No preview available, select a PDF to preview fake scan")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ObscuredOverlay>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
168
frontend/src/components/tools/fakeScan/FakeScanSettings.tsx
Normal file
168
frontend/src/components/tools/fakeScan/FakeScanSettings.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Stack, Select, Switch, NumberInput, Divider, Group } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { FakeScanParameters } from '../../../hooks/tools/fakeScan/useFakeScanParameters';
|
||||||
|
import { getQualityOptions, getRotationOptions } from './constants';
|
||||||
|
import { useAdjustFontSizeToFit } from '../../shared/fitText/textFit';
|
||||||
|
|
||||||
|
export default function FakeScanSettings({
|
||||||
|
parameters,
|
||||||
|
onParameterChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
parameters: FakeScanParameters;
|
||||||
|
onParameterChange: <K extends keyof FakeScanParameters>(
|
||||||
|
key: K,
|
||||||
|
value: FakeScanParameters[K]
|
||||||
|
) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const FitLabel = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
useAdjustFontSizeToFit(ref, { maxLines: 2 });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
lineHeight: 1.15,
|
||||||
|
minHeight: '2.3em',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Select
|
||||||
|
label={<FitLabel>{t('scannerEffect.quality', 'Scan Quality')}</FitLabel>}
|
||||||
|
data={getQualityOptions(t)}
|
||||||
|
value={parameters.quality}
|
||||||
|
onChange={(v) => onParameterChange('quality', (v as any) || 'high')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={<FitLabel>{t('scannerEffect.rotation', 'Rotation Angle')}</FitLabel>}
|
||||||
|
data={getRotationOptions(t)}
|
||||||
|
value={parameters.rotation}
|
||||||
|
onChange={(v) => onParameterChange('rotation', (v as any) || 'slight')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={parameters.advancedEnabled}
|
||||||
|
onChange={(e) => onParameterChange('advancedEnabled', e.currentTarget.checked)}
|
||||||
|
label={<FitLabel>{t('scannerEffect.advancedSettings', 'Enable Advanced Scan Settings')}</FitLabel>}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{parameters.advancedEnabled && (
|
||||||
|
<Stack gap="xs" style={{ border: '1px solid var(--border-color)', padding: 12, borderRadius: 8 }}>
|
||||||
|
<Select
|
||||||
|
label={<FitLabel>{t('scannerEffect.colorspace', 'Colorspace')}</FitLabel>}
|
||||||
|
data={[
|
||||||
|
{ value: 'grayscale', label: t('scannerEffect.colorspace.grayscale', 'Grayscale') },
|
||||||
|
{ value: 'color', label: t('scannerEffect.colorspace.color', 'Color') },
|
||||||
|
]}
|
||||||
|
value={parameters.colorspace}
|
||||||
|
onChange={(v) => onParameterChange('colorspace', (v as any) || 'grayscale')}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.border', 'Border (px)')}</FitLabel>}
|
||||||
|
value={parameters.border}
|
||||||
|
onChange={(v) => onParameterChange('border', Number(v) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.rotate', 'Base Rotation (degrees)')}</FitLabel>}
|
||||||
|
value={parameters.rotate}
|
||||||
|
onChange={(v) => onParameterChange('rotate', Number(v) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.rotateVariance', 'Rotation Variance (degrees)')}</FitLabel>}
|
||||||
|
value={parameters.rotateVariance}
|
||||||
|
onChange={(v) => onParameterChange('rotateVariance', Number(v) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.brightness', 'Brightness')}</FitLabel>}
|
||||||
|
value={parameters.brightness}
|
||||||
|
onChange={(v) => onParameterChange('brightness', Number(v) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.contrast', 'Contrast')}</FitLabel>}
|
||||||
|
value={parameters.contrast}
|
||||||
|
onChange={(v) => onParameterChange('contrast', Number(v) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.blur', 'Blur')}</FitLabel>}
|
||||||
|
value={parameters.blur}
|
||||||
|
onChange={(v) => onParameterChange('blur', Number(v) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.noise', 'Noise')}</FitLabel>}
|
||||||
|
value={parameters.noise}
|
||||||
|
onChange={(v) => onParameterChange('noise', Number(v) || 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group grow>
|
||||||
|
<Switch
|
||||||
|
checked={parameters.yellowish}
|
||||||
|
onChange={(e) => onParameterChange('yellowish', e.currentTarget.checked)}
|
||||||
|
label={<FitLabel>{t('scannerEffect.yellowish', 'Yellowish (simulate old paper)')}</FitLabel>}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={<FitLabel>{t('scannerEffect.resolution', 'Resolution (DPI)')}</FitLabel>}
|
||||||
|
value={parameters.resolution}
|
||||||
|
onChange={(v) => onParameterChange('resolution', Number(v) || 72)}
|
||||||
|
disabled={disabled}
|
||||||
|
step={1}
|
||||||
|
min={72}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
21
frontend/src/components/tools/fakeScan/constants.ts
Normal file
21
frontend/src/components/tools/fakeScan/constants.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { TFunction } from 'i18next';
|
||||||
|
|
||||||
|
export const getQualityOptions = (t: TFunction) => [
|
||||||
|
{ value: 'low', label: t('scannerEffect.quality.low', 'Low') },
|
||||||
|
{ value: 'medium', label: t('scannerEffect.quality.medium', 'Medium') },
|
||||||
|
{ value: 'high', label: t('scannerEffect.quality.high', 'High') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getRotationOptions = (t: TFunction) => [
|
||||||
|
{ value: 'none', label: t('scannerEffect.rotation.none', 'None') },
|
||||||
|
{ value: 'slight', label: t('scannerEffect.rotation.slight', 'Slight') },
|
||||||
|
{ value: 'moderate', label: t('scannerEffect.rotation.moderate', 'Moderate') },
|
||||||
|
{ value: 'severe', label: t('scannerEffect.rotation.severe', 'Severe') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getColorspaceOptions = (t: TFunction) => [
|
||||||
|
{ value: 'grayscale', label: t('scannerEffect.colorspace.grayscale', 'Grayscale') },
|
||||||
|
{ value: 'color', label: t('scannerEffect.colorspace.color', 'Color') },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
@ -56,6 +56,7 @@ export interface ToolFlowConfig {
|
|||||||
steps: MiddleStepConfig[];
|
steps: MiddleStepConfig[];
|
||||||
executeButton?: ExecuteButtonConfig;
|
executeButton?: ExecuteButtonConfig;
|
||||||
review: ReviewStepConfig;
|
review: ReviewStepConfig;
|
||||||
|
preview?: React.ReactNode;
|
||||||
forceStepNumbers?: boolean;
|
forceStepNumbers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +91,9 @@ export function createToolFlow(config: ToolFlowConfig) {
|
|||||||
}, stepConfig.content)
|
}, stepConfig.content)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{config.preview}
|
||||||
|
|
||||||
{/* Execute Button */}
|
{/* Execute Button */}
|
||||||
{config.executeButton && config.executeButton.isVisible !== false && (
|
{config.executeButton && config.executeButton.isVisible !== false && (
|
||||||
<OperationButton
|
<OperationButton
|
||||||
|
@ -67,6 +67,7 @@ import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
|||||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||||
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||||
import Redact from "../tools/Redact";
|
import Redact from "../tools/Redact";
|
||||||
|
import FakeScan from "../tools/FakeScan";
|
||||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||||
import { ToolId } from "../types/toolId";
|
import { ToolId } from "../types/toolId";
|
||||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||||
@ -669,10 +670,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
fakeScan: {
|
fakeScan: {
|
||||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.fakeScan.title", "Scanner Effect"),
|
name: t("home.fakeScan.title", "Scanner Effect"),
|
||||||
component: null,
|
component: FakeScan,
|
||||||
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
|
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
|
||||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||||
|
maxFiles: -1,
|
||||||
|
endpoints: ["scanner-effect"],
|
||||||
|
settingsComponent: React.lazy(() => import("../components/tools/fakeScan/FakeScanSettings")),
|
||||||
synonyms: getSynonyms(t, "fakeScan"),
|
synonyms: getSynonyms(t, "fakeScan"),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
46
frontend/src/hooks/tools/fakeScan/useFakeScanOperation.ts
Normal file
46
frontend/src/hooks/tools/fakeScan/useFakeScanOperation.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||||
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
|
import { FakeScanParameters, defaultParameters } from './useFakeScanParameters';
|
||||||
|
|
||||||
|
export const buildFakeScanFormData = (parameters: FakeScanParameters, file: File): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileInput', file);
|
||||||
|
formData.append('quality', parameters.quality);
|
||||||
|
formData.append('rotation', parameters.rotation);
|
||||||
|
formData.append('advancedEnabled', String(parameters.advancedEnabled));
|
||||||
|
if (parameters.advancedEnabled) {
|
||||||
|
formData.append('colorspace', parameters.colorspace);
|
||||||
|
formData.append('border', String(parameters.border));
|
||||||
|
formData.append('rotate', String(parameters.rotate));
|
||||||
|
formData.append('rotateVariance', String(parameters.rotateVariance));
|
||||||
|
formData.append('brightness', String(parameters.brightness));
|
||||||
|
formData.append('contrast', String(parameters.contrast));
|
||||||
|
formData.append('blur', String(parameters.blur));
|
||||||
|
formData.append('noise', String(parameters.noise));
|
||||||
|
formData.append('yellowish', String(parameters.yellowish));
|
||||||
|
formData.append('resolution', String(parameters.resolution));
|
||||||
|
}
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fakeScanOperationConfig = {
|
||||||
|
toolType: ToolType.singleFile,
|
||||||
|
buildFormData: buildFakeScanFormData,
|
||||||
|
operationType: 'fakeScan',
|
||||||
|
endpoint: '/api/v1/misc/scanner-effect',
|
||||||
|
defaultParameters,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useFakeScanOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useToolOperation<FakeScanParameters>({
|
||||||
|
...fakeScanOperationConfig,
|
||||||
|
getErrorMessage: createStandardErrorHandler(
|
||||||
|
t('fakeScan.error.failed', 'An error occurred while applying the scanner effect.')
|
||||||
|
)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
45
frontend/src/hooks/tools/fakeScan/useFakeScanParameters.ts
Normal file
45
frontend/src/hooks/tools/fakeScan/useFakeScanParameters.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { BaseParameters } from '../../../types/parameters';
|
||||||
|
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||||
|
|
||||||
|
export interface FakeScanParameters extends BaseParameters {
|
||||||
|
quality: 'low' | 'medium' | 'high';
|
||||||
|
rotation: 'none' | 'slight' | 'moderate' | 'severe';
|
||||||
|
advancedEnabled: boolean;
|
||||||
|
colorspace: 'grayscale' | 'color';
|
||||||
|
border: number;
|
||||||
|
rotate: number;
|
||||||
|
rotateVariance: number;
|
||||||
|
brightness: number;
|
||||||
|
contrast: number;
|
||||||
|
blur: number;
|
||||||
|
noise: number;
|
||||||
|
yellowish: boolean;
|
||||||
|
resolution: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultParameters: FakeScanParameters = {
|
||||||
|
quality: 'high',
|
||||||
|
rotation: 'slight',
|
||||||
|
advancedEnabled: false,
|
||||||
|
colorspace: 'grayscale',
|
||||||
|
border: 20,
|
||||||
|
rotate: 0,
|
||||||
|
rotateVariance: 2,
|
||||||
|
brightness: 1.0,
|
||||||
|
contrast: 1.0,
|
||||||
|
blur: 1.0,
|
||||||
|
noise: 8.0,
|
||||||
|
yellowish: false,
|
||||||
|
resolution: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FakeScanParametersHook = BaseParametersHook<FakeScanParameters>;
|
||||||
|
|
||||||
|
export const useFakeScanParameters = (): FakeScanParametersHook => {
|
||||||
|
return useBaseParameters<FakeScanParameters>({
|
||||||
|
defaultParameters,
|
||||||
|
endpointName: 'scanner-effect',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
100
frontend/src/tools/FakeScan.tsx
Normal file
100
frontend/src/tools/FakeScan.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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 { useFakeScanParameters } from '../hooks/tools/fakeScan/useFakeScanParameters';
|
||||||
|
import { useFakeScanOperation } from '../hooks/tools/fakeScan/useFakeScanOperation';
|
||||||
|
import FakeScanBasicSettings from '../components/tools/fakeScan/FakeScanBasicSettings';
|
||||||
|
import FakeScanAdvancedPanel from '../components/tools/fakeScan/FakeScanAdvancedPanel';
|
||||||
|
import FakeScanPreview from '../components/tools/fakeScan/FakeScanPreview';
|
||||||
|
import { useAccordionSteps } from '../hooks/tools/shared/useAccordionSteps';
|
||||||
|
|
||||||
|
const FakeScan = (props: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const base = useBaseTool(
|
||||||
|
'fakeScan',
|
||||||
|
useFakeScanParameters,
|
||||||
|
useFakeScanOperation,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
|
||||||
|
enum FakeScanStep {
|
||||||
|
NONE = 'none',
|
||||||
|
BASIC = 'basic',
|
||||||
|
ADVANCED = 'advanced'
|
||||||
|
}
|
||||||
|
|
||||||
|
const accordion = useAccordionSteps<FakeScanStep>({
|
||||||
|
noneValue: FakeScanStep.NONE,
|
||||||
|
initialStep: FakeScanStep.BASIC,
|
||||||
|
stateConditions: {
|
||||||
|
hasFiles: base.hasFiles,
|
||||||
|
hasResults: base.hasResults
|
||||||
|
},
|
||||||
|
afterResults: base.handleSettingsReset
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstFile = base.selectedFiles[0] || null;
|
||||||
|
const canPreview = !!firstFile && firstFile.type === 'application/pdf';
|
||||||
|
|
||||||
|
return createToolFlow({
|
||||||
|
files: {
|
||||||
|
selectedFiles: base.selectedFiles,
|
||||||
|
isCollapsed: base.hasResults,
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: t("scannerEffect.basicSettings", "Basic Settings"),
|
||||||
|
isCollapsed: accordion.getCollapsedState(FakeScanStep.BASIC),
|
||||||
|
onCollapsedClick: () => accordion.handleStepToggle(FakeScanStep.BASIC),
|
||||||
|
content: (
|
||||||
|
<FakeScanBasicSettings
|
||||||
|
parameters={base.params.parameters}
|
||||||
|
onParameterChange={base.params.updateParameter}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("scannerEffect.advancedSettings", "Advanced Settings"),
|
||||||
|
isCollapsed: accordion.getCollapsedState(FakeScanStep.ADVANCED),
|
||||||
|
onCollapsedClick: () => accordion.handleStepToggle(FakeScanStep.ADVANCED),
|
||||||
|
content: (
|
||||||
|
<FakeScanAdvancedPanel
|
||||||
|
parameters={base.params.parameters}
|
||||||
|
onParameterChange={base.params.updateParameter}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
preview: !base.hasResults ? (
|
||||||
|
|
||||||
|
<FakeScanPreview
|
||||||
|
parameters={base.params.parameters}
|
||||||
|
file={canPreview ? firstFile : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
) : null,
|
||||||
|
executeButton: {
|
||||||
|
text: t('fakeScan.submit', 'Create Scanner Effect'),
|
||||||
|
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('fakeScan.title', 'Scanner Effect Results'),
|
||||||
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
|
},
|
||||||
|
forceStepNumbers: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FakeScan as ToolComponent;
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user