From 756cbc4780319100dba7ea3d213b9fe6fc93c8ff Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:51:55 +0100 Subject: [PATCH 1/5] Feature/v2/remove pages (#4445) # Description of Changes - Addition of the remove pages tool - Addition of the remove blank pages tool --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/en-GB/translation.json | 84 +++++++++++++++++-- .../public/locales/en-US/translation.json | 84 +++++++++++++++++-- .../removeBlanks/RemoveBlanksSettings.tsx | 75 +++++++++++++++++ .../tools/removePages/RemovePagesSettings.tsx | 39 +++++++++ .../tooltips/useRemoveBlanksTips.ts | 41 +++++++++ .../components/tooltips/useRemovePagesTips.ts | 34 ++++++++ .../src/data/useTranslatedToolRegistry.tsx | 10 ++- .../removeBlanks/useRemoveBlanksOperation.ts | 43 ++++++++++ .../removeBlanks/useRemoveBlanksParameters.ts | 26 ++++++ .../removePages/useRemovePagesOperation.ts | 32 +++++++ .../removePages/useRemovePagesParameters.ts | 21 +++++ frontend/src/tools/RemoveBlanks.tsx | 70 ++++++++++++++++ frontend/src/tools/RemovePages.tsx | 64 ++++++++++++++ frontend/src/utils/pageSelection.ts | 23 +++++ 14 files changed, 626 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/tools/removeBlanks/RemoveBlanksSettings.tsx create mode 100644 frontend/src/components/tools/removePages/RemovePagesSettings.tsx create mode 100644 frontend/src/components/tooltips/useRemoveBlanksTips.ts create mode 100644 frontend/src/components/tooltips/useRemovePagesTips.ts 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/hooks/tools/removePages/useRemovePagesOperation.ts create mode 100644 frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts create mode 100644 frontend/src/tools/RemoveBlanks.tsx create mode 100644 frontend/src/tools/RemovePages.tsx create mode 100644 frontend/src/utils/pageSelection.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 49afabb8c..ccc811781 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1125,15 +1125,46 @@ "removePages": { "tags": "Remove pages,delete pages", "title": "Remove Pages", - "pageNumbers": "Pages to Remove", - "pageNumbersPlaceholder": "e.g. 1,3,5-7", - "pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", + "pageNumbers": { + "label": "Pages to Remove", + "placeholder": "e.g., 1,3,5-8,10", + "error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)" + }, "filenamePrefix": "pages_removed", "files": { "placeholder": "Select a PDF file in the main view to get started" }, "settings": { - "title": "Page Selection" + "title": "Settings" + }, + "tooltip": { + "header": { + "title": "Remove Pages Settings" + }, + "pageNumbers": { + "title": "Page Selection", + "text": "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions.", + "bullet1": "Individual pages: 1,3,5 (removes pages 1, 3, and 5)", + "bullet2": "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)", + "bullet3": "Mathematical: 2n+1 (removes odd pages)", + "bullet4": "Open ranges: 5- (removes from page 5 to end)" + }, + "examples": { + "title": "Common Examples", + "text": "Here are some common page selection patterns:", + "bullet1": "Remove first page: 1", + "bullet2": "Remove last 3 pages: -3", + "bullet3": "Remove every other page: 2n", + "bullet4": "Remove specific scattered pages: 1,5,10,15" + }, + "safety": { + "title": "Safety Tips", + "text": "Important considerations when removing pages:", + "bullet1": "Always preview your selection before processing", + "bullet2": "Keep a backup of your original file", + "bullet3": "Page numbers start from 1, not 0", + "bullet4": "Invalid page numbers will be ignored" + } }, "error": { "failed": "An error occurred whilst removing pages." @@ -1492,11 +1523,46 @@ "tags": "cleanup,streamline,non-content,organize", "title": "Remove Blanks", "header": "Remove Blank Pages", - "threshold": "Pixel Whiteness Threshold:", - "thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.", - "whitePercent": "White Percent (%):", - "whitePercentDesc": "Percent of page that must be 'white' pixels to be removed", - "submit": "Remove Blanks" + "settings": { + "title": "Settings" + }, + "threshold": { + "label": "Pixel Whiteness Threshold" + }, + "whitePercent": { + "label": "White Percentage Threshold", + "unit": "%" + }, + "includeBlankPages": { + "label": "Include detected blank pages" + }, + "tooltip": { + "header": { + "title": "Remove Blank Pages Settings" + }, + "threshold": { + "title": "Pixel Whiteness Threshold", + "text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.", + "bullet1": "0 = Pure black (most restrictive)", + "bullet2": "128 = Medium grey", + "bullet3": "255 = Pure white (least restrictive)" + }, + "whitePercent": { + "title": "White Percentage Threshold", + "text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.", + "bullet1": "Lower values (e.g., 80%) = More pages removed", + "bullet2": "Higher values (e.g., 95%) = Only very blank pages removed", + "bullet3": "Use higher values for documents with light backgrounds" + }, + "includeBlankPages": { + "title": "Include Detected Blank Pages", + "text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.", + "bullet1": "Useful for reviewing what was removed", + "bullet2": "Helps verify the detection accuracy", + "bullet3": "Can be disabled to reduce output file size" + } + }, + "submit": "Remove blank pages" }, "removeAnnotations": { "tags": "comments,highlight,notes,markup,remove", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 4b93181b8..359b27160 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -745,15 +745,46 @@ "removePages": { "tags": "Remove pages,delete pages", "title": "Remove Pages", - "pageNumbers": "Pages to Remove", - "pageNumbersPlaceholder": "e.g. 1,3,5-7", - "pageNumbersHelp": "Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7", + "pageNumbers": { + "label": "Pages to Remove", + "placeholder": "e.g., 1,3,5-8,10", + "error": "Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)" + }, "filenamePrefix": "pages_removed", "files": { "placeholder": "Select a PDF file in the main view to get started" }, "settings": { - "title": "Page Selection" + "title": "Settings" + }, + "tooltip": { + "header": { + "title": "Remove Pages Settings" + }, + "pageNumbers": { + "title": "Page Selection", + "text": "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions.", + "bullet1": "Individual pages: 1,3,5 (removes pages 1, 3, and 5)", + "bullet2": "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)", + "bullet3": "Mathematical: 2n+1 (removes odd pages)", + "bullet4": "Open ranges: 5- (removes from page 5 to end)" + }, + "examples": { + "title": "Common Examples", + "text": "Here are some common page selection patterns:", + "bullet1": "Remove first page: 1", + "bullet2": "Remove last 3 pages: -3", + "bullet3": "Remove every other page: 2n", + "bullet4": "Remove specific scattered pages: 1,5,10,15" + }, + "safety": { + "title": "Safety Tips", + "text": "Important considerations when removing pages:", + "bullet1": "Always preview your selection before processing", + "bullet2": "Keep a backup of your original file", + "bullet3": "Page numbers start from 1, not 0", + "bullet4": "Invalid page numbers will be ignored" + } }, "error": { "failed": "An error occurred while removing pages." @@ -1013,11 +1044,46 @@ "tags": "cleanup,streamline,non-content,organize", "title": "Remove Blanks", "header": "Remove Blank Pages", - "threshold": "Pixel Whiteness Threshold:", - "thresholdDesc": "Threshold for determining how white a white pixel must be to be classed as 'White'. 0 = Black, 255 pure white.", - "whitePercent": "White Percent (%):", - "whitePercentDesc": "Percent of page that must be 'white' pixels to be removed", - "submit": "Remove Blanks" + "settings": { + "title": "Settings" + }, + "threshold": { + "label": "Pixel Whiteness Threshold" + }, + "whitePercent": { + "label": "White Percentage Threshold", + "unit": "%" + }, + "includeBlankPages": { + "label": "Include detected blank pages" + }, + "tooltip": { + "header": { + "title": "Remove Blank Pages Settings" + }, + "threshold": { + "title": "Pixel Whiteness Threshold", + "text": "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page.", + "bullet1": "0 = Pure black (most restrictive)", + "bullet2": "128 = Medium gray", + "bullet3": "255 = Pure white (least restrictive)" + }, + "whitePercent": { + "title": "White Percentage Threshold", + "text": "Sets the minimum percentage of white pixels required for a page to be considered blank and removed.", + "bullet1": "Lower values (e.g., 80%) = More pages removed", + "bullet2": "Higher values (e.g., 95%) = Only very blank pages removed", + "bullet3": "Use higher values for documents with light backgrounds" + }, + "includeBlankPages": { + "title": "Include Detected Blank Pages", + "text": "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document.", + "bullet1": "Useful for reviewing what was removed", + "bullet2": "Helps verify the detection accuracy", + "bullet3": "Can be disabled to reduce output file size" + } + }, + "submit": "Remove blank pages" }, "removeAnnotations": { "tags": "comments,highlight,notes,markup,remove", diff --git a/frontend/src/components/tools/removeBlanks/RemoveBlanksSettings.tsx b/frontend/src/components/tools/removeBlanks/RemoveBlanksSettings.tsx new file mode 100644 index 000000000..fa4ec6e55 --- /dev/null +++ b/frontend/src/components/tools/removeBlanks/RemoveBlanksSettings.tsx @@ -0,0 +1,75 @@ +import { Stack, Text, Checkbox, Slider, NumberInput, Group } 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='' + min={0} + max={255} + disabled={disabled} + /> + + + + + {t('removeBlanks.whitePercent.label', 'White Percent')} + + + onParameterChange('whitePercent', typeof v === 'number' ? v : 0.1)} + min={0.1} + max={100} + step={0.1} + size="sm" + rightSection="%" + style={{ width: '80px' }} + disabled={disabled} + /> + onParameterChange('whitePercent', value)} + min={0.1} + max={100} + step={0.1} + style={{ flex: 1 }} + disabled={disabled} + /> + + + + + onParameterChange('includeBlankPages', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('removeBlanks.includeBlankPages.label', 'Include detected blank pages')} +
+ } + /> +
+
+ ); +}; + +export default RemoveBlanksSettings; + + diff --git a/frontend/src/components/tools/removePages/RemovePagesSettings.tsx b/frontend/src/components/tools/removePages/RemovePagesSettings.tsx new file mode 100644 index 000000000..99856e29c --- /dev/null +++ b/frontend/src/components/tools/removePages/RemovePagesSettings.tsx @@ -0,0 +1,39 @@ +import { Stack, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { RemovePagesParameters } from "../../../hooks/tools/removePages/useRemovePagesParameters"; +import { validatePageNumbers } from "../../../utils/pageSelection"; + +interface RemovePagesSettingsProps { + parameters: RemovePagesParameters; + onParameterChange: (key: K, value: RemovePagesParameters[K]) => void; + disabled?: boolean; +} + +const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }: RemovePagesSettingsProps) => { + const { t } = useTranslation(); + + const handlePageNumbersChange = (value: string) => { + // Allow user to type naturally - don't normalize input in real-time + onParameterChange('pageNumbers', value); + }; + + // Check if current input is valid + const isValid = validatePageNumbers(parameters.pageNumbers); + const hasValue = parameters.pageNumbers.trim().length > 0; + + return ( + + handlePageNumbersChange(event.currentTarget.value)} + placeholder={t('removePages.pageNumbers.placeholder', 'e.g., 1,3,5-8,10')} + disabled={disabled} + required + error={hasValue && !isValid ? t('removePages.pageNumbers.error', 'Invalid page number format. Use numbers, ranges (1-5), or mathematical expressions (2n+1)') : undefined} + /> + + ); +}; + +export default RemovePagesSettings; diff --git a/frontend/src/components/tooltips/useRemoveBlanksTips.ts b/frontend/src/components/tooltips/useRemoveBlanksTips.ts new file mode 100644 index 000000000..a011bd4ee --- /dev/null +++ b/frontend/src/components/tooltips/useRemoveBlanksTips.ts @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useRemoveBlanksTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("removeBlanks.tooltip.header.title", "Remove Blank Pages Settings"), + }, + tips: [ + { + title: t("removeBlanks.tooltip.threshold.title", "Pixel Whiteness Threshold"), + description: t("removeBlanks.tooltip.threshold.text", "Controls how white a pixel must be to be considered 'white'. This helps determine what counts as a blank area on the page."), + bullets: [ + t("removeBlanks.tooltip.threshold.bullet1", "0 = Pure black (most restrictive)"), + t("removeBlanks.tooltip.threshold.bullet2", "128 = Medium gray"), + t("removeBlanks.tooltip.threshold.bullet3", "255 = Pure white (least restrictive)") + ] + }, + { + title: t("removeBlanks.tooltip.whitePercent.title", "White Percentage Threshold"), + description: t("removeBlanks.tooltip.whitePercent.text", "Sets the minimum percentage of white pixels required for a page to be considered blank and removed."), + bullets: [ + t("removeBlanks.tooltip.whitePercent.bullet1", "Lower values (e.g., 80%) = More pages removed"), + t("removeBlanks.tooltip.whitePercent.bullet2", "Higher values (e.g., 95%) = Only very blank pages removed"), + t("removeBlanks.tooltip.whitePercent.bullet3", "Use higher values for documents with light backgrounds") + ] + }, + { + title: t("removeBlanks.tooltip.includeBlankPages.title", "Include Detected Blank Pages"), + description: t("removeBlanks.tooltip.includeBlankPages.text", "When enabled, creates a separate PDF containing all the blank pages that were detected and removed from the original document."), + bullets: [ + t("removeBlanks.tooltip.includeBlankPages.bullet1", "Useful for reviewing what was removed"), + t("removeBlanks.tooltip.includeBlankPages.bullet2", "Helps verify the detection accuracy"), + t("removeBlanks.tooltip.includeBlankPages.bullet3", "Can be disabled to reduce output file size") + ] + } + ] + }; +}; diff --git a/frontend/src/components/tooltips/useRemovePagesTips.ts b/frontend/src/components/tooltips/useRemovePagesTips.ts new file mode 100644 index 000000000..1da2a1ad1 --- /dev/null +++ b/frontend/src/components/tooltips/useRemovePagesTips.ts @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useRemovePagesTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("removePages.tooltip.header.title", "Remove Pages Settings"), + }, + tips: [ + { + title: t("removePages.tooltip.pageNumbers.title", "Page Selection"), + description: t("removePages.tooltip.pageNumbers.text", "Specify which pages to remove from your PDF. You can select individual pages, ranges, or use mathematical expressions."), + bullets: [ + t("removePages.tooltip.pageNumbers.bullet1", "Individual pages: 1,3,5 (removes pages 1, 3, and 5)"), + t("removePages.tooltip.pageNumbers.bullet2", "Page ranges: 1-5,10-15 (removes pages 1-5 and 10-15)"), + t("removePages.tooltip.pageNumbers.bullet3", "Mathematical: 2n+1 (removes odd pages)"), + t("removePages.tooltip.pageNumbers.bullet4", "Open ranges: 5- (removes from page 5 to end)") + ] + }, + { + title: t("removePages.tooltip.examples.title", "Common Examples"), + description: t("removePages.tooltip.examples.text", "Here are some common page selection patterns:"), + bullets: [ + t("removePages.tooltip.examples.bullet1", "Remove first page: 1"), + t("removePages.tooltip.examples.bullet2", "Remove last 3 pages: -3"), + t("removePages.tooltip.examples.bullet3", "Remove every other page: 2n"), + t("removePages.tooltip.examples.bullet4", "Remove specific scattered pages: 1,5,10,15") + ] + } + ] + }; +}; diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index f3050ea01..3e5612eb0 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -8,6 +8,8 @@ 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 RemovePages from "../tools/RemovePages"; import RemovePassword from "../tools/RemovePassword"; import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy"; import AddWatermark from "../tools/AddWatermark"; @@ -414,18 +416,22 @@ export function useFlatToolRegistry(): ToolRegistry { removePages: { icon: , name: t("home.removePages.title", "Remove Pages"), - component: null, + component: RemovePages, description: t("home.removePages.desc", "Remove specific pages from a PDF document"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.REMOVAL, + maxFiles: 1, + endpoints: ["remove-pages"], }, "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..479132d6b --- /dev/null +++ b/frontend/src/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts @@ -0,0 +1,43 @@ +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)); + // Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP + return formData; +}; + +export const removeBlanksOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRemoveBlanksFormData, + operationType: 'remove-blanks', + endpoint: '/api/v1/misc/remove-blanks', + defaultParameters, +} as const satisfies ToolOperationConfig; + +export const useRemoveBlanksOperation = () => { + const { t } = useTranslation(); + const { extractZipFiles } = useToolResources(); + + const responseHandler = useCallback(async (blob: Blob): Promise => { + // Backend always returns a ZIP file containing the processed PDFs + return await extractZipFiles(blob); + }, [extractZipFiles]); + + return useToolOperation({ + ...removeBlanksOperationConfig, + responseHandler, + 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/hooks/tools/removePages/useRemovePagesOperation.ts b/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts new file mode 100644 index 000000000..95296fd07 --- /dev/null +++ b/frontend/src/hooks/tools/removePages/useRemovePagesOperation.ts @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { RemovePagesParameters, defaultParameters } from './useRemovePagesParameters'; +// import { useToolResources } from '../shared/useToolResources'; + +export const buildRemovePagesFormData = (parameters: RemovePagesParameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + const cleaned = parameters.pageNumbers.replace(/\s+/g, ''); + formData.append('pageNumbers', cleaned); + return formData; +}; + +export const removePagesOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildRemovePagesFormData, + operationType: 'remove-pages', + endpoint: '/api/v1/general/remove-pages', + defaultParameters, +} as const satisfies ToolOperationConfig; + +export const useRemovePagesOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...removePagesOperationConfig, + getErrorMessage: createStandardErrorHandler( + t('removePages.error.failed', 'Failed to remove pages') + ) + }); +}; diff --git a/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts b/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts new file mode 100644 index 000000000..31484f54e --- /dev/null +++ b/frontend/src/hooks/tools/removePages/useRemovePagesParameters.ts @@ -0,0 +1,21 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; +import { validatePageNumbers } from '../../../utils/pageSelection'; + +export interface RemovePagesParameters extends BaseParameters { + pageNumbers: string; // comma-separated page numbers or ranges (e.g., "1,3,5-8") +} + +export const defaultParameters: RemovePagesParameters = { + pageNumbers: '', +}; + +export type RemovePagesParametersHook = BaseParametersHook; + +export const useRemovePagesParameters = (): RemovePagesParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'remove-pages', + validateFn: (p) => validatePageNumbers(p.pageNumbers), + }); +}; diff --git a/frontend/src/tools/RemoveBlanks.tsx b/frontend/src/tools/RemoveBlanks.tsx new file mode 100644 index 000000000..d7478bdac --- /dev/null +++ b/frontend/src/tools/RemoveBlanks.tsx @@ -0,0 +1,70 @@ +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"; +import { useRemoveBlanksTips } from "../components/tooltips/useRemoveBlanksTips"; + +const RemoveBlanks = (props: BaseToolProps) => { + const { t } = useTranslation(); + const tooltipContent = useRemoveBlanksTips(); + + const base = useBaseTool( + 'remove-blanks', + useRemoveBlanksParameters, + useRemoveBlanksOperation, + props + ); + + const settingsContent = ( + + ); + + const handleSettingsClick = () => { + if (base.hasResults) { + base.handleSettingsReset(); + } + }; + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t("removeBlanks.settings.title", "Settings"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: handleSettingsClick, + content: settingsContent, + tooltip: tooltipContent, + }, + ], + 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; + + diff --git a/frontend/src/tools/RemovePages.tsx b/frontend/src/tools/RemovePages.tsx new file mode 100644 index 000000000..5ae0f6934 --- /dev/null +++ b/frontend/src/tools/RemovePages.tsx @@ -0,0 +1,64 @@ +import React 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 { useRemovePagesParameters } from "../hooks/tools/removePages/useRemovePagesParameters"; +import { useRemovePagesOperation } from "../hooks/tools/removePages/useRemovePagesOperation"; +import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings"; +import { useRemovePagesTips } from "../components/tooltips/useRemovePagesTips"; + +const RemovePages = (props: BaseToolProps) => { + const { t } = useTranslation(); + const tooltipContent = useRemovePagesTips(); + + const base = useBaseTool( + 'remove-pages', + useRemovePagesParameters, + useRemovePagesOperation, + props + ); + + + const settingsContent = ( + + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + }, + steps: [ + { + title: t("removePages.settings.title", "Settings"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + content: settingsContent, + tooltip: tooltipContent, + }, + ], + executeButton: { + text: t("removePages.submit", "Remove 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("removePages.results.title", "Pages Removed"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +RemovePages.tool = () => useRemovePagesOperation; + +export default RemovePages as ToolComponent; diff --git a/frontend/src/utils/pageSelection.ts b/frontend/src/utils/pageSelection.ts new file mode 100644 index 000000000..4909581bb --- /dev/null +++ b/frontend/src/utils/pageSelection.ts @@ -0,0 +1,23 @@ +export const validatePageNumbers = (pageNumbers: string): boolean => { + if (!pageNumbers.trim()) return false; + + // Normalize input for validation: remove spaces around commas and other spaces + const normalized = pageNumbers.replace(/\s*,\s*/g, ',').replace(/\s+/g, ''); + const parts = normalized.split(','); + + // Regular expressions for different page number formats + const allToken = /^all$/i; // Select all pages + const singlePageRegex = /^[1-9]\d*$/; // Single page: positive integers only (no 0) + const rangeRegex = /^[1-9]\d*-(?:[1-9]\d*)?$/; // Range: 1-5 or open range 10- + const mathRegex = /^(?=.*n)[0-9n+\-*/() ]+$/; // Mathematical expressions with n and allowed chars + + return parts.every(part => { + if (!part) return false; + return ( + allToken.test(part) || + singlePageRegex.test(part) || + rangeRegex.test(part) || + mathRegex.test(part) + ); + }); +}; From a5693ee1164acd23ec31d313e53823d510721024 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 18 Sep 2025 10:41:39 +0100 Subject: [PATCH 2/5] V2 change metadata (#4433) # Description of Changes Add Change Metadata tool --- frontend/package-lock.json | 24 +++ frontend/package.json | 1 + .../public/locales/en-GB/translation.json | 135 +++++++++++-- .../ChangeMetadataSingleStep.tsx | 99 ++++++++++ .../steps/AdvancedOptionsStep.tsx | 60 ++++++ .../steps/CustomMetadataStep.tsx | 74 +++++++ .../changeMetadata/steps/DeleteAllStep.tsx | 28 +++ .../steps/DocumentDatesStep.tsx | 42 ++++ .../steps/StandardMetadataStep.tsx | 71 +++++++ .../tooltips/useChangeMetadataTips.ts | 108 +++++++++++ .../src/data/useTranslatedToolRegistry.tsx | 9 +- .../useChangeMetadataOperation.test.ts | 144 ++++++++++++++ .../useChangeMetadataOperation.ts | 71 +++++++ .../useChangeMetadataParameters.test.ts | 168 ++++++++++++++++ .../useChangeMetadataParameters.ts | 136 +++++++++++++ .../changeMetadata/useMetadataExtraction.ts | 70 +++++++ frontend/src/index.tsx | 1 + frontend/src/services/pdfMetadataService.ts | 181 ++++++++++++++++++ frontend/src/services/pdfWorkerManager.ts | 7 +- frontend/src/tools/ChangeMetadata.tsx | 164 ++++++++++++++++ frontend/src/types/metadata.ts | 24 +++ 21 files changed, 1597 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx create mode 100644 frontend/src/components/tools/changeMetadata/steps/AdvancedOptionsStep.tsx create mode 100644 frontend/src/components/tools/changeMetadata/steps/CustomMetadataStep.tsx create mode 100644 frontend/src/components/tools/changeMetadata/steps/DeleteAllStep.tsx create mode 100644 frontend/src/components/tools/changeMetadata/steps/DocumentDatesStep.tsx create mode 100644 frontend/src/components/tools/changeMetadata/steps/StandardMetadataStep.tsx create mode 100644 frontend/src/components/tooltips/useChangeMetadataTips.ts create mode 100644 frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts create mode 100644 frontend/src/hooks/tools/changeMetadata/useChangeMetadataOperation.ts create mode 100644 frontend/src/hooks/tools/changeMetadata/useChangeMetadataParameters.test.ts create mode 100644 frontend/src/hooks/tools/changeMetadata/useChangeMetadataParameters.ts create mode 100644 frontend/src/hooks/tools/changeMetadata/useMetadataExtraction.ts create mode 100644 frontend/src/services/pdfMetadataService.ts create mode 100644 frontend/src/tools/ChangeMetadata.tsx create mode 100644 frontend/src/types/metadata.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 342f0512f..f16d1f9cb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.14.0", "@iconify/react": "^6.0.0", "@mantine/core": "^8.0.1", + "@mantine/dates": "^8.0.1", "@mantine/dropzone": "^8.0.1", "@mantine/hooks": "^8.0.1", "@mui/icons-material": "^7.1.0", @@ -1653,6 +1654,22 @@ "react-dom": "^18.x || ^19.x" } }, + "node_modules/@mantine/dates": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.0.1.tgz", + "integrity": "sha512-YCmV5jiGE9Ts2uhNS217IA1Hd5kAa8oaEtfnU0bS1sL36zKEf2s6elmzY718XdF8tFil0jJWAj0jiCrA3/udMg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "@mantine/core": "8.0.1", + "@mantine/hooks": "8.0.1", + "dayjs": ">=1.0.0", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, "node_modules/@mantine/dropzone": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.0.1.tgz", @@ -4367,6 +4384,13 @@ "node": ">=18" } }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT", + "peer": true + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0b14a8ffc..214830cd2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "@emotion/styled": "^11.14.0", "@iconify/react": "^6.0.0", "@mantine/core": "^8.0.1", + "@mantine/dates": "^8.0.1", "@mantine/dropzone": "^8.0.1", "@mantine/hooks": "^8.0.1", "@mui/icons-material": "^7.1.0", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index ccc811781..24ae3e1af 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1220,24 +1220,127 @@ }, "changeMetadata": { "tags": "Title,author,date,creation,time,publisher,producer,stats", - "title": "Change Metadata", "header": "Change Metadata", - "selectText": { - "1": "Please edit the variables you wish to change", - "2": "Delete all metadata", - "3": "Show Custom Metadata:", - "4": "Other Metadata:", - "5": "Add Custom Metadata Entry" + "submit": "Change", + "filenamePrefix": "metadata", + "settings": { + "title": "Metadata Settings" }, - "author": "Author:", - "creationDate": "Creation Date (yyyy/MM/dd HH:mm:ss):", - "creator": "Creator:", - "keywords": "Keywords:", - "modDate": "Modification Date (yyyy/MM/dd HH:mm:ss):", - "producer": "Producer:", - "subject": "Subject:", - "trapped": "Trapped:", - "submit": "Change" + "standardFields": { + "title": "Standard Fields" + }, + "deleteAll": { + "label": "Remove Existing Metadata", + "checkbox": "Delete all metadata" + }, + "title": { + "label": "Title", + "placeholder": "Document title" + }, + "author": { + "label": "Author", + "placeholder": "Document author" + }, + "subject": { + "label": "Subject", + "placeholder": "Document subject" + }, + "keywords": { + "label": "Keywords", + "placeholder": "Document keywords" + }, + "creator": { + "label": "Creator", + "placeholder": "Document creator" + }, + "producer": { + "label": "Producer", + "placeholder": "Document producer" + }, + "dates": { + "title": "Date Fields" + }, + "creationDate": { + "label": "Creation Date", + "placeholder": "Creation date" + }, + "modificationDate": { + "label": "Modification Date", + "placeholder": "Modification date" + }, + "trapped": { + "label": "Trapped Status", + "unknown": "Unknown", + "true": "True", + "false": "False" + }, + "advanced": { + "title": "Advanced Options" + }, + "customFields": { + "title": "Custom Metadata", + "description": "Add custom metadata fields to the document", + "add": "Add Field", + "key": "Key", + "keyPlaceholder": "Custom key", + "value": "Value", + "valuePlaceholder": "Custom value", + "remove": "Remove" + }, + "results": { + "title": "Updated PDFs" + }, + "error": { + "failed": "An error occurred while changing the PDF metadata." + }, + "tooltip": { + "header": { + "title": "PDF Metadata Overview" + }, + "standardFields": { + "title": "Standard Fields", + "text": "Common PDF metadata fields that describe the document.", + "bullet1": "Title: Document name or heading", + "bullet2": "Author: Person who created the document", + "bullet3": "Subject: Brief description of content", + "bullet4": "Keywords: Search terms for the document", + "bullet5": "Creator/Producer: Software used to create the PDF" + }, + "dates": { + "title": "Date Fields", + "text": "When the document was created and modified.", + "bullet1": "Creation Date: When original document was made", + "bullet2": "Modification Date: When last changed" + }, + "options": { + "title": "Additional Options", + "text": "Custom fields and privacy controls.", + "bullet1": "Custom Metadata: Add your own key-value pairs", + "bullet2": "Trapped Status: High-quality printing setting", + "bullet3": "Delete All: Remove all metadata for privacy" + }, + "deleteAll": { + "title": "Remove Existing Metadata", + "text": "Complete metadata deletion to ensure privacy." + }, + "customFields": { + "title": "Custom Metadata", + "text": "Add your own custom key-value metadata pairs.", + "bullet1": "Add any custom fields relevant to your document", + "bullet2": "Examples: Department, Project, Version, Status", + "bullet3": "Both key and value are required for each entry" + }, + "advanced": { + "title": "Advanced Options", + "trapped": { + "title": "Trapped Status", + "description": "Indicates if document is prepared for high-quality printing.", + "bullet1": "True: Document has been trapped for printing", + "bullet2": "False: Document has not been trapped", + "bullet3": "Unknown: Trapped status is not specified" + } + } + } }, "fileToPDF": { "tags": "transformation,format,document,picture,slide,text,conversion,office,docs,word,excel,powerpoint", diff --git a/frontend/src/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx b/frontend/src/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx new file mode 100644 index 000000000..ef95718e9 --- /dev/null +++ b/frontend/src/components/tools/changeMetadata/ChangeMetadataSingleStep.tsx @@ -0,0 +1,99 @@ +import { Stack, Divider, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ChangeMetadataParameters, createCustomMetadataFunctions } from "../../../hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { useMetadataExtraction } from "../../../hooks/tools/changeMetadata/useMetadataExtraction"; +import DeleteAllStep from "./steps/DeleteAllStep"; +import StandardMetadataStep from "./steps/StandardMetadataStep"; +import DocumentDatesStep from "./steps/DocumentDatesStep"; +import AdvancedOptionsStep from "./steps/AdvancedOptionsStep"; + +interface ChangeMetadataSingleStepProps { + parameters: ChangeMetadataParameters; + onParameterChange: (key: K, value: ChangeMetadataParameters[K]) => void; + disabled?: boolean; +} + +const ChangeMetadataSingleStep = ({ + parameters, + onParameterChange, + disabled = false +}: ChangeMetadataSingleStepProps) => { + const { t } = useTranslation(); + + // Get custom metadata functions using the utility + const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } = createCustomMetadataFunctions( + parameters, + onParameterChange + ); + + // Extract metadata from uploaded files + const { isExtractingMetadata } = useMetadataExtraction({ + updateParameter: onParameterChange, + }); + + const isDeleteAllEnabled = parameters.deleteAll; + const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata; + + return ( + + {/* Delete All */} + + + {t('changeMetadata.deleteAll.label', 'Delete All Metadata')} + + + + + + + {/* Standard Metadata Fields */} + + + {t('changeMetadata.standardFields.title', 'Standard Metadata')} + + + + + + + {/* Document Dates */} + + + {t('changeMetadata.dates.title', 'Document Dates')} + + + + + + + {/* Advanced Options */} + + + {t('changeMetadata.advanced.title', 'Advanced Options')} + + + + + ); +}; + +export default ChangeMetadataSingleStep; diff --git a/frontend/src/components/tools/changeMetadata/steps/AdvancedOptionsStep.tsx b/frontend/src/components/tools/changeMetadata/steps/AdvancedOptionsStep.tsx new file mode 100644 index 000000000..0edffe21d --- /dev/null +++ b/frontend/src/components/tools/changeMetadata/steps/AdvancedOptionsStep.tsx @@ -0,0 +1,60 @@ +import { Stack, Select, Divider } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ChangeMetadataParameters } from "../../../../hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { TrappedStatus } from "../../../../types/metadata"; +import CustomMetadataStep from "./CustomMetadataStep"; + +interface AdvancedOptionsStepProps { + parameters: ChangeMetadataParameters; + onParameterChange: (key: K, value: ChangeMetadataParameters[K]) => void; + disabled?: boolean; + addCustomMetadata: (key?: string, value?: string) => void; + removeCustomMetadata: (id: string) => void; + updateCustomMetadata: (id: string, key: string, value: string) => void; +} + +const AdvancedOptionsStep = ({ + parameters, + onParameterChange, + disabled = false, + addCustomMetadata, + removeCustomMetadata, + updateCustomMetadata +}: AdvancedOptionsStepProps) => { + const { t } = useTranslation(); + + return ( + + {/* Trapped Status */} +