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": {
|
||||
"tags": "scan,simulate,create",
|
||||
"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": {
|
||||
"tags": "bookmarks,contents,edit",
|
||||
|
@ -671,6 +671,12 @@
|
||||
"title": "API Documentation",
|
||||
"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": {
|
||||
"tags": "color,replace,invert",
|
||||
"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[];
|
||||
executeButton?: ExecuteButtonConfig;
|
||||
review: ReviewStepConfig;
|
||||
preview?: React.ReactNode;
|
||||
forceStepNumbers?: boolean;
|
||||
}
|
||||
|
||||
@ -90,6 +91,9 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
}, stepConfig.content)
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{config.preview}
|
||||
|
||||
{/* Execute Button */}
|
||||
{config.executeButton && config.executeButton.isVisible !== false && (
|
||||
<OperationButton
|
||||
|
@ -67,6 +67,7 @@ import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||
import Redact from "../tools/Redact";
|
||||
import FakeScan from "../tools/FakeScan";
|
||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||
import { ToolId } from "../types/toolId";
|
||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||
@ -669,10 +670,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
fakeScan: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
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"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["scanner-effect"],
|
||||
settingsComponent: React.lazy(() => import("../components/tools/fakeScan/FakeScanSettings")),
|
||||
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