mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 09:29:24 +00:00
Addition of the Remove Blank Pages tool
This commit is contained in:
parent
f3fd85d777
commit
243b1aaa0b
@ -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: <K extends keyof RemoveBlanksParameters>(key: K, value: RemoveBlanksParameters[K]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RemoveBlanksSettings = ({ parameters, onParameterChange, disabled = false }: RemoveBlanksSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<NumberInputWithUnit
|
||||||
|
label={t('removeBlanks.threshold.label', 'Pixel Whiteness Threshold')}
|
||||||
|
value={parameters.threshold}
|
||||||
|
onChange={(v) => onParameterChange('threshold', typeof v === 'string' ? Number(v) : v)}
|
||||||
|
unit={t('removeBlanks.threshold.unit', '')}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('removeBlanks.threshold.desc', "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<NumberInputWithUnit
|
||||||
|
label={t('removeBlanks.whitePercent.label', 'White Percent')}
|
||||||
|
value={parameters.whitePercent}
|
||||||
|
onChange={(v) => onParameterChange('whitePercent', typeof v === 'string' ? Number(v) : v)}
|
||||||
|
unit={t('removeBlanks.whitePercent.unit', '%')}
|
||||||
|
min={0.1}
|
||||||
|
max={100}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('removeBlanks.whitePercent.desc', "Percent of page that must be 'white' pixels to be removed")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Checkbox
|
||||||
|
checked={parameters.includeBlankPages}
|
||||||
|
onChange={(event) => onParameterChange('includeBlankPages', event.currentTarget.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
label={
|
||||||
|
<div>
|
||||||
|
<Text size="sm">{t('removeBlanks.includeBlankPages.label', 'Include detected blank pages')}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('removeBlanks.includeBlankPages.desc', 'Include the detected blank pages as a separate PDF in the output')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RemoveBlanksSettings;
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ import ConvertPanel from "../tools/Convert";
|
|||||||
import Sanitize from "../tools/Sanitize";
|
import Sanitize from "../tools/Sanitize";
|
||||||
import AddPassword from "../tools/AddPassword";
|
import AddPassword from "../tools/AddPassword";
|
||||||
import ChangePermissions from "../tools/ChangePermissions";
|
import ChangePermissions from "../tools/ChangePermissions";
|
||||||
|
import RemoveBlanks from "../tools/RemoveBlanks";
|
||||||
import RemovePassword from "../tools/RemovePassword";
|
import RemovePassword from "../tools/RemovePassword";
|
||||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||||
import AddWatermark from "../tools/AddWatermark";
|
import AddWatermark from "../tools/AddWatermark";
|
||||||
@ -416,10 +417,12 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
"remove-blank-pages": {
|
"remove-blank-pages": {
|
||||||
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.removeBlanks.title", "Remove Blank Pages"),
|
name: t("home.removeBlanks.title", "Remove Blank Pages"),
|
||||||
component: null,
|
component: RemoveBlanks,
|
||||||
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
|
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
|
||||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
subcategoryId: SubcategoryId.REMOVAL,
|
subcategoryId: SubcategoryId.REMOVAL,
|
||||||
|
maxFiles: 1,
|
||||||
|
endpoints: ["remove-blanks"],
|
||||||
},
|
},
|
||||||
"remove-annotations": {
|
"remove-annotations": {
|
||||||
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
@ -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<RemoveBlanksParameters>;
|
||||||
|
|
||||||
|
export const useRemoveBlanksOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { extractZipFiles } = useToolResources();
|
||||||
|
|
||||||
|
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
|
||||||
|
// 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[^>]*>([^<]+)<\/title>/i)?.[1] ||
|
||||||
|
text.match(/<h1[^>]*>([^<]+)<\/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<RemoveBlanksParameters>({
|
||||||
|
...removeBlanksOperationConfig,
|
||||||
|
responseHandler,
|
||||||
|
filePrefix: t('removeBlanks.filenamePrefix', 'noblank') + '_',
|
||||||
|
getErrorMessage: createStandardErrorHandler(
|
||||||
|
t('removeBlanks.error.failed', 'Failed to remove blank pages')
|
||||||
|
)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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<RemoveBlanksParameters>;
|
||||||
|
|
||||||
|
export const useRemoveBlanksParameters = (): RemoveBlanksParametersHook => {
|
||||||
|
return useBaseParameters({
|
||||||
|
defaultParameters,
|
||||||
|
endpointName: 'remove-blanks',
|
||||||
|
validateFn: (p) => p.threshold >= 0 && p.threshold <= 255 && p.whitePercent > 0 && p.whitePercent <= 100,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
88
frontend/src/tools/RemoveBlanks.tsx
Normal file
88
frontend/src/tools/RemoveBlanks.tsx
Normal file
@ -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 = (
|
||||||
|
<RemoveBlanksSettings
|
||||||
|
parameters={base.params.parameters}
|
||||||
|
onParameterChange={base.params.updateParameter}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user