From 243b1aaa0b646910bb8d4c9bc9f8cb5bee2abac0 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Mon, 15 Sep 2025 12:29:43 +0100 Subject: [PATCH] Addition of the Remove Blank Pages tool --- .../removeBlanks/RemoveBlanksSettings.tsx | 68 ++++++++++++++ .../src/data/useTranslatedToolRegistry.tsx | 5 +- .../removeBlanks/useRemoveBlanksOperation.ts | 70 +++++++++++++++ .../removeBlanks/useRemoveBlanksParameters.ts | 26 ++++++ frontend/src/tools/RemoveBlanks.tsx | 88 +++++++++++++++++++ 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/tools/removeBlanks/RemoveBlanksSettings.tsx create mode 100644 frontend/src/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts create mode 100644 frontend/src/hooks/tools/removeBlanks/useRemoveBlanksParameters.ts create mode 100644 frontend/src/tools/RemoveBlanks.tsx diff --git a/frontend/src/components/tools/removeBlanks/RemoveBlanksSettings.tsx b/frontend/src/components/tools/removeBlanks/RemoveBlanksSettings.tsx new file mode 100644 index 000000000..f40435267 --- /dev/null +++ b/frontend/src/components/tools/removeBlanks/RemoveBlanksSettings.tsx @@ -0,0 +1,68 @@ +import { Stack, Text, Checkbox } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import NumberInputWithUnit from "../shared/NumberInputWithUnit"; +import { RemoveBlanksParameters } from "../../../hooks/tools/removeBlanks/useRemoveBlanksParameters"; + +interface RemoveBlanksSettingsProps { + parameters: RemoveBlanksParameters; + onParameterChange: (key: K, value: RemoveBlanksParameters[K]) => void; + disabled?: boolean; +} + +const RemoveBlanksSettings = ({ parameters, onParameterChange, disabled = false }: RemoveBlanksSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + onParameterChange('threshold', typeof v === 'string' ? Number(v) : v)} + unit={t('removeBlanks.threshold.unit', '')} + min={0} + max={255} + disabled={disabled} + /> + + {t('removeBlanks.threshold.desc', "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.")} + + + + + onParameterChange('whitePercent', typeof v === 'string' ? Number(v) : v)} + unit={t('removeBlanks.whitePercent.unit', '%')} + min={0.1} + max={100} + disabled={disabled} + /> + + {t('removeBlanks.whitePercent.desc', "Percent of page that must be 'white' pixels to be removed")} + + + + + onParameterChange('includeBlankPages', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('removeBlanks.includeBlankPages.label', 'Include detected blank pages')} + + {t('removeBlanks.includeBlankPages.desc', 'Include the detected blank pages as a separate PDF in the output')} + +
+ } + /> +
+
+ ); +}; + +export default RemoveBlanksSettings; + + diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index c88d46fec..2f8b98001 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -8,6 +8,7 @@ import ConvertPanel from "../tools/Convert"; import Sanitize from "../tools/Sanitize"; import AddPassword from "../tools/AddPassword"; import ChangePermissions from "../tools/ChangePermissions"; +import RemoveBlanks from "../tools/RemoveBlanks"; import RemovePassword from "../tools/RemovePassword"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; import AddWatermark from "../tools/AddWatermark"; @@ -416,10 +417,12 @@ export function useFlatToolRegistry(): ToolRegistry { "remove-blank-pages": { icon: , name: t("home.removeBlanks.title", "Remove Blank Pages"), - component: null, + component: RemoveBlanks, description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, + maxFiles: 1, + endpoints: ["remove-blanks"], }, "remove-annotations": { icon: , diff --git a/frontend/src/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts b/frontend/src/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts new file mode 100644 index 000000000..d341db7aa --- /dev/null +++ b/frontend/src/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts @@ -0,0 +1,70 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { RemoveBlanksParameters, defaultParameters } from './useRemoveBlanksParameters'; +import { useToolResources } from '../shared/useToolResources'; + +export const buildRemoveBlanksFormData = (parameters: RemoveBlanksParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + formData.append('threshold', String(parameters.threshold)); + formData.append('whitePercent', String(parameters.whitePercent)); + formData.append('includeBlankPages', String(parameters.includeBlankPages)); + return formData; +}; + +export const removeBlanksOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRemoveBlanksFormData, + operationType: 'remove-blanks', + endpoint: '/api/v1/misc/remove-blanks', + filePrefix: 'noblank_', + defaultParameters, +} as const satisfies ToolOperationConfig; + +export const useRemoveBlanksOperation = () => { + const { t } = useTranslation(); + const { extractZipFiles } = useToolResources(); + + const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise => { + // Try to detect zip vs pdf + const headBuf = await blob.slice(0, 4).arrayBuffer(); + const head = new TextDecoder().decode(new Uint8Array(headBuf)); + + // ZIP: extract PDFs inside (nonBlankPages, blankPages, etc.) + if (head.startsWith('PK')) { + const files = await extractZipFiles(blob); + if (files.length > 0) return files; + } + + // PDF fallback: return as single file + if (head.startsWith('%PDF')) { + const base = originalFiles[0]?.name?.replace(/\.[^.]+$/, '') || 'document'; + return [new File([blob], `noblank_${base}.pdf`, { type: 'application/pdf' })]; + } + + // Unknown blob type + const textBuf = await blob.slice(0, 1024).arrayBuffer(); + const text = new TextDecoder().decode(new Uint8Array(textBuf)); + if (/error|exception|html/i.test(text)) { + const title = + text.match(/]*>([^<]+)<\/title>/i)?.[1] || + text.match(/]*>([^<]+)<\/h1>/i)?.[1] || + 'Unknown error'; + throw new Error(`Remove blanks service error: ${title}`); + } + throw new Error('Unexpected response format from remove blanks service'); + }, [extractZipFiles]); + + return useToolOperation({ + ...removeBlanksOperationConfig, + responseHandler, + filePrefix: t('removeBlanks.filenamePrefix', 'noblank') + '_', + getErrorMessage: createStandardErrorHandler( + t('removeBlanks.error.failed', 'Failed to remove blank pages') + ) + }); +}; + + diff --git a/frontend/src/hooks/tools/removeBlanks/useRemoveBlanksParameters.ts b/frontend/src/hooks/tools/removeBlanks/useRemoveBlanksParameters.ts new file mode 100644 index 000000000..b6716c681 --- /dev/null +++ b/frontend/src/hooks/tools/removeBlanks/useRemoveBlanksParameters.ts @@ -0,0 +1,26 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface RemoveBlanksParameters extends BaseParameters { + threshold: number; // 0-255 + whitePercent: number; // 0.1-100 + includeBlankPages: boolean; // whether to include detected blank pages in output +} + +export const defaultParameters: RemoveBlanksParameters = { + threshold: 10, + whitePercent: 99.9, + includeBlankPages: false, +}; + +export type RemoveBlanksParametersHook = BaseParametersHook; + +export const useRemoveBlanksParameters = (): RemoveBlanksParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'remove-blanks', + validateFn: (p) => p.threshold >= 0 && p.threshold <= 255 && p.whitePercent > 0 && p.whitePercent <= 100, + }); +}; + + diff --git a/frontend/src/tools/RemoveBlanks.tsx b/frontend/src/tools/RemoveBlanks.tsx new file mode 100644 index 000000000..56c74039c --- /dev/null +++ b/frontend/src/tools/RemoveBlanks.tsx @@ -0,0 +1,88 @@ +import { useState, useEffect } from "react"; +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 { useRemoveBlanksParameters } from "../hooks/tools/removeBlanks/useRemoveBlanksParameters"; +import { useRemoveBlanksOperation } from "../hooks/tools/removeBlanks/useRemoveBlanksOperation"; +import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings"; + +const RemoveBlanks = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'remove-blanks', + useRemoveBlanksParameters, + useRemoveBlanksOperation, + props + ); + + // Step expansion state management + const [expandedStep, setExpandedStep] = useState<"files" | "advanced" | null>("files"); + + // Auto-expand advanced when files are selected + useEffect(() => { + if (base.selectedFiles.length > 0 && expandedStep === "files") { + setExpandedStep("advanced"); + } + }, [base.selectedFiles.length, expandedStep]); + + // Collapse all steps when results appear + useEffect(() => { + if (base.hasResults) { + setExpandedStep(null); + } + }, [base.hasResults]); + + const settingsContent = ( + + ); + + const handleAdvancedClick = () => { + if (base.hasResults) { + base.handleSettingsReset(); + } else { + if (!base.hasFiles) return; // Only allow if files are selected + setExpandedStep(expandedStep === "advanced" ? null : "advanced"); + } + }; + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t("removeBlanks.advanced.title", "Advanced"), + isCollapsed: expandedStep !== "advanced", + onCollapsedClick: handleAdvancedClick, + content: settingsContent, + }, + ], + executeButton: { + text: t("removeBlanks.submit", "Remove blank pages"), + loadingText: t("loading"), + onClick: base.handleExecute, + isVisible: !base.hasResults, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("removeBlanks.results.title", "Removed Blank Pages"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +RemoveBlanks.tool = () => useRemoveBlanksOperation; + +export default RemoveBlanks as ToolComponent; + +