diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c30bc7c55..9a70d2e43 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index ae23ddd73..b56b29d5e 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -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", diff --git a/frontend/src/components/tools/fakeScan/FakeScanAdvancedPanel.tsx b/frontend/src/components/tools/fakeScan/FakeScanAdvancedPanel.tsx new file mode 100644 index 000000000..00cf7cf84 --- /dev/null +++ b/frontend/src/components/tools/fakeScan/FakeScanAdvancedPanel.tsx @@ -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: (key: K, value: FakeScanParameters[K]) => void; + disabled?: boolean; +}; + +const FitLabel = ({ children }: { children: React.ReactNode }) => { + const ref = useRef(null); + useAdjustFontSizeToFit(ref, { maxLines: 2 }); + return ( +
{children}
+ ); +}; + +export default function FakeScanAdvancedPanel({ parameters, onParameterChange, disabled }: Props) { + const { t } = useTranslation(); + + const setAdvanced = () => { + if (!parameters.advancedEnabled) onParameterChange('advancedEnabled', true as any); + }; + + return ( + + +
+ {t('scannerEffect.brightness', 'Brightness')} + { setAdvanced(); onParameterChange('brightness', v as any); }} disabled={disabled} /> +
+
+ {t('scannerEffect.contrast', 'Contrast')} + { setAdvanced(); onParameterChange('contrast', v as any); }} disabled={disabled} /> +
+
+ + +
+ {t('scannerEffect.rotation', 'Rotation')} + { setAdvanced(); onParameterChange('rotate', v as any); onParameterChange('rotateVariance', 0 as any); }} disabled={disabled} /> +
+
+ {t('scannerEffect.blur', 'Blur')} + { setAdvanced(); onParameterChange('blur', v as any); }} disabled={disabled} /> +
+
+ + +
+ {t('scannerEffect.noise', 'Noise')} + { setAdvanced(); onParameterChange('noise', v as any); }} disabled={disabled} /> +
+
+ {t('scannerEffect.yellowish', 'Yellowish (simulate old paper)')} +
+ { setAdvanced(); onParameterChange('yellowish', e.currentTarget.checked as any); }} disabled={disabled} /> +
+ + + +
+ {t('scannerEffect.resolution', 'Resolution (DPI)')} + { setAdvanced(); onParameterChange('resolution', Number(v) || 72 as any); }} disabled={disabled} min={72} step={1} /> +
+
+ + + ); +} + + diff --git a/frontend/src/components/tools/fakeScan/FakeScanBasicSettings.tsx b/frontend/src/components/tools/fakeScan/FakeScanBasicSettings.tsx new file mode 100644 index 000000000..11ddcf376 --- /dev/null +++ b/frontend/src/components/tools/fakeScan/FakeScanBasicSettings.tsx @@ -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(null); + useAdjustFontSizeToFit(ref, { maxLines: 2 }); + return ( +
{children}
+ ); +}; + +export default function FakeScanBasicSettings({ + parameters, + onParameterChange, + disabled, +}: { + parameters: FakeScanParameters; + onParameterChange: ( + key: K, + value: FakeScanParameters[K] + ) => void; + disabled?: boolean; +}) { + const { t } = useTranslation(); + + return ( + + {t('scannerEffect.rotation', 'Rotation Angle')}} + data={getRotationOptions(t)} + value={parameters.rotation} + onChange={(v) => onParameterChange('rotation', (v as any) || 'slight')} + disabled={disabled} + /> + + {t('scannerEffect.quality', 'Scan Quality')}} + data={getQualityOptions(t)} + value={parameters.quality} + onChange={(v) => onParameterChange('quality', (v as any) || 'high')} + disabled={disabled} + /> + + {t('scannerEffect.colorspace', 'Colorspace')}} + 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} + /> + + + + + {t('scannerEffect.border', 'Border (px)')}} + value={parameters.border} + onChange={(v) => onParameterChange('border', Number(v) || 0)} + disabled={disabled} + min={0} + step={1} + /> + {t('scannerEffect.rotate', 'Base Rotation (degrees)')}} + value={parameters.rotate} + onChange={(v) => onParameterChange('rotate', Number(v) || 0)} + disabled={disabled} + step={1} + /> + {t('scannerEffect.rotateVariance', 'Rotation Variance (degrees)')}} + value={parameters.rotateVariance} + onChange={(v) => onParameterChange('rotateVariance', Number(v) || 0)} + disabled={disabled} + step={1} + /> + + + + + + {t('scannerEffect.brightness', 'Brightness')}} + value={parameters.brightness} + onChange={(v) => onParameterChange('brightness', Number(v) || 0)} + disabled={disabled} + step={0.01} + /> + {t('scannerEffect.contrast', 'Contrast')}} + value={parameters.contrast} + onChange={(v) => onParameterChange('contrast', Number(v) || 0)} + disabled={disabled} + step={0.01} + /> + + + + + + {t('scannerEffect.blur', 'Blur')}} + value={parameters.blur} + onChange={(v) => onParameterChange('blur', Number(v) || 0)} + disabled={disabled} + step={0.1} + /> + {t('scannerEffect.noise', 'Noise')}} + value={parameters.noise} + onChange={(v) => onParameterChange('noise', Number(v) || 0)} + disabled={disabled} + step={0.1} + /> + + + + + + onParameterChange('yellowish', e.currentTarget.checked)} + label={{t('scannerEffect.yellowish', 'Yellowish (simulate old paper)')}} + disabled={disabled} + /> + {t('scannerEffect.resolution', 'Resolution (DPI)')}} + value={parameters.resolution} + onChange={(v) => onParameterChange('resolution', Number(v) || 72)} + disabled={disabled} + step={1} + min={72} + /> + + + )} + + ); +} + + diff --git a/frontend/src/components/tools/fakeScan/constants.ts b/frontend/src/components/tools/fakeScan/constants.ts new file mode 100644 index 000000000..b83838d77 --- /dev/null +++ b/frontend/src/components/tools/fakeScan/constants.ts @@ -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') }, +]; + + diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index 4724648c8..70391e81a 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -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 && ( , 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"), }, diff --git a/frontend/src/hooks/tools/fakeScan/useFakeScanOperation.ts b/frontend/src/hooks/tools/fakeScan/useFakeScanOperation.ts new file mode 100644 index 000000000..f9bc4bb1d --- /dev/null +++ b/frontend/src/hooks/tools/fakeScan/useFakeScanOperation.ts @@ -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({ + ...fakeScanOperationConfig, + getErrorMessage: createStandardErrorHandler( + t('fakeScan.error.failed', 'An error occurred while applying the scanner effect.') + ) + }); +}; + + diff --git a/frontend/src/hooks/tools/fakeScan/useFakeScanParameters.ts b/frontend/src/hooks/tools/fakeScan/useFakeScanParameters.ts new file mode 100644 index 000000000..613314004 --- /dev/null +++ b/frontend/src/hooks/tools/fakeScan/useFakeScanParameters.ts @@ -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; + +export const useFakeScanParameters = (): FakeScanParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'scanner-effect', + }); +}; + + diff --git a/frontend/src/tools/FakeScan.tsx b/frontend/src/tools/FakeScan.tsx new file mode 100644 index 000000000..71a986774 --- /dev/null +++ b/frontend/src/tools/FakeScan.tsx @@ -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({ + 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: ( + + ), + }, + { + title: t("scannerEffect.advancedSettings", "Advanced Settings"), + isCollapsed: accordion.getCollapsedState(FakeScanStep.ADVANCED), + onCollapsedClick: () => accordion.handleStepToggle(FakeScanStep.ADVANCED), + content: ( + + ), + }, + ], + preview: !base.hasResults ? ( + + + + ) : 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; + +