Merge pull request #57 from iib0011/split-pdf

feat: split pdf
This commit is contained in:
Ibrahima G. Coulibaly
2025-03-26 05:56:52 +00:00
committed by GitHub
14 changed files with 450 additions and 50 deletions

65
.idea/workspace.xml generated
View File

@@ -4,10 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: background removal"> <list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="feat: split pdf">
<change afterPath="$PROJECT_DIR$/src/components/input/ToolPdfInput.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/image/png/remove-background/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/image/png/remove-background/index.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/service.ts" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -124,15 +127,16 @@
"Vitest.mergeText.executor": "Run", "Vitest.mergeText.executor": "Run",
"Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor": "Run", "Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor": "Run",
"Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor": "Run", "Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor": "Run",
"Vitest.parsePageRanges.executor": "Run",
"Vitest.removeDuplicateLines function.executor": "Run", "Vitest.removeDuplicateLines function.executor": "Run",
"Vitest.removeDuplicateLines function.newlines option.executor": "Run", "Vitest.removeDuplicateLines function.newlines option.executor": "Run",
"Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run", "Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run",
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run", "Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
"Vitest.replaceText function.executor": "Run", "Vitest.replaceText function.executor": "Run",
"git-widget-placeholder": "main", "git-widget-placeholder": "split-pdf",
"ignore.virus.scanning.warn.message": "true", "ignore.virus.scanning.warn.message": "true",
"kotlin-language-version-configured": "true", "kotlin-language-version-configured": "true",
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src/pages/tools/list/duplicate/index.tsx", "last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src/components/input",
"node.js.detected.package.eslint": "true", "node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true", "node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)", "node.js.selected.package.eslint": "(autodetect)",
@@ -166,11 +170,11 @@
</component> </component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\components\input" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\.husky" /> <recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\.husky" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" /> <recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\assets" /> <recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\assets" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\.github" /> <recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\.github" />
<recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components\options" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" /> <recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
@@ -180,7 +184,7 @@
<recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components\options" /> <recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components\options" />
</key> </key>
</component> </component>
<component name="RunManager" selected="npm.dev"> <component name="RunManager" selected="Vitest.parsePageRanges">
<configuration name="Create transparent PNG.should make png color transparent" type="JavaScriptTestRunnerPlaywright" temporary="true" nameIsGenerated="true"> <configuration name="Create transparent PNG.should make png color transparent" type="JavaScriptTestRunnerPlaywright" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" /> <node-interpreter value="project" />
<playwright-package value="$PROJECT_DIR$/node_modules/@playwright/test" /> <playwright-package value="$PROJECT_DIR$/node_modules/@playwright/test" />
@@ -194,6 +198,19 @@
</test-names> </test-names>
<method v="2" /> <method v="2" />
</configuration> </configuration>
<configuration name="parsePageRanges" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$" />
<vitest-options value="--run" />
<envs />
<scope-kind value="SUITE" />
<test-file value="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/service.test.ts" />
<test-names>
<test-name value="parsePageRanges" />
</test-names>
<method v="2" />
</configuration>
<configuration name="replaceText function (regexp mode).should return the original text when passed an invalid regexp" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true"> <configuration name="replaceText function (regexp mode).should return the original text when passed an invalid regexp" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" /> <node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" /> <vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
@@ -224,16 +241,6 @@
<envs /> <envs />
<method v="2" /> <method v="2" />
</configuration> </configuration>
<configuration name="test" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="test" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
<configuration name="test:e2e" type="js.build_tools.npm" temporary="true" nameIsGenerated="true"> <configuration name="test:e2e" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" /> <package-json value="$PROJECT_DIR$/package.json" />
<command value="run" /> <command value="run" />
@@ -245,19 +252,19 @@
<method v="2" /> <method v="2" />
</configuration> </configuration>
<list> <list>
<item itemvalue="npm.test" />
<item itemvalue="npm.test:e2e" /> <item itemvalue="npm.test:e2e" />
<item itemvalue="npm.dev" /> <item itemvalue="npm.dev" />
<item itemvalue="Playwright.Create transparent PNG.should make png color transparent" /> <item itemvalue="Playwright.Create transparent PNG.should make png color transparent" />
<item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" /> <item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
</list> </list>
<recent_temporary> <recent_temporary>
<list> <list>
<item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="npm.dev" /> <item itemvalue="npm.dev" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" /> <item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
<item itemvalue="Playwright.Create transparent PNG.should make png color transparent" /> <item itemvalue="Playwright.Create transparent PNG.should make png color transparent" />
<item itemvalue="npm.test:e2e" /> <item itemvalue="npm.test:e2e" />
<item itemvalue="npm.test" />
</list> </list>
</recent_temporary> </recent_temporary>
</component> </component>
@@ -342,14 +349,6 @@
<workItem from="1741567442768" duration="14127000" /> <workItem from="1741567442768" duration="14127000" />
<workItem from="1741971589699" duration="371000" /> <workItem from="1741971589699" duration="371000" />
</task> </task>
<task id="LOCAL-00117" summary="chore: style">
<option name="closed" value="true" />
<created>1740491274739</created>
<option name="number" value="00117" />
<option name="presentableId" value="LOCAL-00117" />
<option name="project" value="LOCAL" />
<updated>1740491274739</updated>
</task>
<task id="LOCAL-00118" summary="style: background svg"> <task id="LOCAL-00118" summary="style: background svg">
<option name="closed" value="true" /> <option name="closed" value="true" />
<created>1740491737480</created> <created>1740491737480</created>
@@ -734,7 +733,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1742961898820</updated> <updated>1742961898820</updated>
</task> </task>
<option name="localTasksCounter" value="166" /> <task id="LOCAL-00166" summary="feat: split pdf">
<option name="closed" value="true" />
<created>1742967844908</created>
<option name="number" value="00166" />
<option name="presentableId" value="LOCAL-00166" />
<option name="project" value="LOCAL" />
<updated>1742967844908</updated>
</task>
<option name="localTasksCounter" value="167" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -781,7 +788,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" /> <option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" /> <option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" /> <option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="chore: img" />
<MESSAGE value="docs: readme" /> <MESSAGE value="docs: readme" />
<MESSAGE value="feat: remove duplicate lines" /> <MESSAGE value="feat: remove duplicate lines" />
<MESSAGE value="fix: tsc" /> <MESSAGE value="fix: tsc" />
@@ -806,7 +812,8 @@
<MESSAGE value="feat: trim video" /> <MESSAGE value="feat: trim video" />
<MESSAGE value="refactor: file inputs" /> <MESSAGE value="refactor: file inputs" />
<MESSAGE value="feat: background removal" /> <MESSAGE value="feat: background removal" />
<option name="LAST_COMMIT_MESSAGE" value="feat: background removal" /> <MESSAGE value="feat: split pdf" />
<option name="LAST_COMMIT_MESSAGE" value="feat: split pdf" />
</component> </component>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />

37
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"morsee": "^1.0.9", "morsee": "^1.0.9",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"omggif": "^1.0.10", "omggif": "^1.0.10",
"pdf-lib": "^1.17.1",
"playwright": "^1.45.0", "playwright": "^1.45.0",
"rc-slider": "^11.1.8", "rc-slider": "^11.1.8",
"react": "^18.3.1", "react": "^18.3.1",
@@ -2326,6 +2327,24 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -8255,6 +8274,24 @@
"through": "~2.3" "through": "~2.3"
} }
}, },
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/peek-readable": { "node_modules/peek-readable": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",

View File

@@ -48,6 +48,7 @@
"morsee": "^1.0.9", "morsee": "^1.0.9",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"omggif": "^1.0.10", "omggif": "^1.0.10",
"pdf-lib": "^1.17.1",
"playwright": "^1.45.0", "playwright": "^1.45.0",
"rc-slider": "^11.1.8", "rc-slider": "^11.1.8",
"react": "^18.3.1", "react": "^18.3.1",

View File

@@ -1,4 +1,4 @@
import React, { ReactNode, useContext } from 'react'; import React, { ReactNode, useContext, useEffect } from 'react';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { Formik, FormikValues, useFormikContext } from 'formik'; import { Formik, FormikValues, useFormikContext } from 'formik';
import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions'; import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
@@ -13,10 +13,12 @@ import { CustomSnackBarContext } from '../contexts/CustomSnackBarContext';
const FormikListenerComponent = <T,>({ const FormikListenerComponent = <T,>({
input, input,
compute compute,
onValuesChange
}: { }: {
input: any; input: any;
compute: (optionsValues: T, input: any) => void; compute: (optionsValues: T, input: any) => void;
onValuesChange?: (values: T) => void;
}) => { }) => {
const { values } = useFormikContext<T>(); const { values } = useFormikContext<T>();
const { showSnackBar } = useContext(CustomSnackBarContext); const { showSnackBar } = useContext(CustomSnackBarContext);
@@ -30,40 +32,31 @@ const FormikListenerComponent = <T,>({
} }
}, [values, input, showSnackBar]); }, [values, input, showSnackBar]);
useEffect(() => {
onValuesChange?.(values);
}, [onValuesChange, values]);
return null; // This component doesn't render anything return null; // This component doesn't render anything
}; };
interface ToolContentProps<T, I> extends ToolComponentProps { interface ToolContentProps<T, I> extends ToolComponentProps {
// Input/Output components
inputComponent?: ReactNode; inputComponent?: ReactNode;
resultComponent: ReactNode; resultComponent: ReactNode;
renderCustomInput?: ( renderCustomInput?: (
values: T, values: T,
setFieldValue: (fieldName: string, value: any) => void setFieldValue: (fieldName: string, value: any) => void
) => ReactNode; ) => ReactNode;
// Tool options
initialValues: T; initialValues: T;
getGroups: GetGroupsType<T> | null; getGroups: GetGroupsType<T> | null;
// Computation function
compute: (optionsValues: T, input: I) => void; compute: (optionsValues: T, input: I) => void;
// Tool info (optional)
toolInfo?: { toolInfo?: {
title: string; title: string;
description?: string; description?: string;
}; };
// Input value to pass to the compute function
input?: I; input?: I;
exampleCards?: CardExampleType<T>[]; exampleCards?: CardExampleType<T>[];
setInput?: React.Dispatch<React.SetStateAction<I>>; setInput?: React.Dispatch<React.SetStateAction<I>>;
// Validation schema (optional)
validationSchema?: any; validationSchema?: any;
onValuesChange?: (values: T) => void;
} }
export default function ToolContent<T extends FormikValues, I>({ export default function ToolContent<T extends FormikValues, I>({
@@ -78,7 +71,8 @@ export default function ToolContent<T extends FormikValues, I>({
input, input,
setInput, setInput,
validationSchema, validationSchema,
renderCustomInput renderCustomInput,
onValuesChange
}: ToolContentProps<T, I>) { }: ToolContentProps<T, I>) {
return ( return (
<Box> <Box>
@@ -98,7 +92,11 @@ export default function ToolContent<T extends FormikValues, I>({
} }
result={resultComponent} result={resultComponent}
/> />
<FormikListenerComponent<T> compute={compute} input={input} /> <FormikListenerComponent<T>
compute={compute}
input={input}
onValuesChange={onValuesChange}
/>
<ToolOptions getGroups={getGroups} /> <ToolOptions getGroups={getGroups} />
{toolInfo && toolInfo.title && toolInfo.description && ( {toolInfo && toolInfo.title && toolInfo.description && (

View File

@@ -14,7 +14,7 @@ import greyPattern from '@assets/grey-pattern.png';
interface BaseFileInputComponentProps extends BaseFileInputProps { interface BaseFileInputComponentProps extends BaseFileInputProps {
children: (props: { preview: string | undefined }) => ReactNode; children: (props: { preview: string | undefined }) => ReactNode;
type: 'image' | 'video' | 'audio'; type: 'image' | 'video' | 'audio' | 'pdf';
} }
export default function BaseFileInput({ export default function BaseFileInput({

View File

@@ -0,0 +1,23 @@
import React, { useRef } from 'react';
import BaseFileInput from './BaseFileInput';
import { BaseFileInputProps } from './file-input-utils';
interface PdfFileInputProps extends BaseFileInputProps {}
export default function ToolPdfInput({ ...props }: PdfFileInputProps) {
const pdfRef = useRef<HTMLIFrameElement>(null);
return (
<BaseFileInput {...props} type={'pdf'}>
{({ preview }) => (
<iframe
ref={pdfRef}
src={preview}
width="100%"
height="100%"
style={{ maxWidth: '500px' }}
/>
)}
</BaseFileInput>
);
}

View File

@@ -63,12 +63,14 @@ export default function ToolFileResult({
} }
}; };
type SupportedFileType = 'image' | 'video' | 'audio' | 'pdf' | 'unknown';
// Determine the file type based on MIME type // Determine the file type based on MIME type
const getFileType = () => { const getFileType = (): SupportedFileType => {
if (!value) return 'unknown'; if (!value) return 'unknown';
if (value.type.startsWith('image/')) return 'image'; if (value.type.startsWith('image/')) return 'image';
if (value.type.startsWith('video/')) return 'video'; if (value.type.startsWith('video/')) return 'video';
if (value.type.startsWith('audio/')) return 'audio'; if (value.type.startsWith('audio/')) return 'audio';
if (value.type.startsWith('application/pdf')) return 'pdf';
return 'unknown'; return 'unknown';
}; };
@@ -135,6 +137,14 @@ export default function ToolFileResult({
style={{ width: '100%', maxWidth: '500px' }} style={{ width: '100%', maxWidth: '500px' }}
/> />
)} )}
{fileType === 'pdf' && (
<iframe
src={preview}
width="100%"
height="100%"
style={{ maxWidth: '500px' }}
/>
)}
{fileType === 'unknown' && ( {fileType === 'unknown' && (
<Box sx={{ padding: 2, textAlign: 'center' }}> <Box sx={{ padding: 2, textAlign: 'center' }}>
File processed successfully. Click download to save the File processed successfully. Click download to save the

View File

@@ -0,0 +1,4 @@
import { meta as splitPdfMeta } from './split-pdf/meta';
import { DefinedTool } from '@tools/defineTool';
export const pdfTools: DefinedTool[] = [splitPdfMeta];

View File

@@ -0,0 +1,181 @@
import { Box, Typography } from '@mui/material';
import React, { useEffect, useRef, useState } from 'react';
import ToolFileInput from '@components/input/ToolFileInput';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { parsePageRanges, splitPdf } from './service';
import { CardExampleType } from '@components/examples/ToolExamples';
import { PDFDocument } from 'pdf-lib';
import { FormikProps } from 'formik';
import ToolPdfInput from '@components/input/ToolPdfInput';
type InitialValuesType = {
pageRanges: string;
};
const initialValues: InitialValuesType = {
pageRanges: ''
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Extract Specific Pages',
description: 'Extract pages 1, 5, 6, 7, and 8 from a PDF document.',
sampleText: '',
sampleResult: '',
sampleOptions: {
pageRanges: '1,5-8'
}
},
{
title: 'Extract First and Last Pages',
description: 'Extract only the first and last pages from a PDF document.',
sampleText: '',
sampleResult: '',
sampleOptions: {
pageRanges: '1,10'
}
},
{
title: 'Extract a Range of Pages',
description: 'Extract a continuous range of pages from a PDF document.',
sampleText: '',
sampleResult: '',
sampleOptions: {
pageRanges: '3-7'
}
}
];
export default function SplitPdf({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [totalPages, setTotalPages] = useState<number>(0);
const [pageRangePreview, setPageRangePreview] = useState<string>('');
// Get the total number of pages when a PDF is uploaded
useEffect(() => {
const getPdfInfo = async () => {
if (!input) {
setTotalPages(0);
return;
}
try {
const arrayBuffer = await input.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
setTotalPages(pdf.getPageCount());
} catch (error) {
console.error('Error getting PDF info:', error);
setTotalPages(0);
}
};
getPdfInfo();
}, [input]);
const onValuesChange = (values: InitialValuesType) => {
const { pageRanges } = values;
if (!totalPages || !pageRanges?.trim()) {
setPageRangePreview('');
return;
}
try {
const count = parsePageRanges(pageRanges, totalPages).length;
setPageRangePreview(
`${count} page${count !== 1 ? 's' : ''} will be extracted`
);
} catch (error) {
setPageRangePreview('');
}
};
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
try {
setIsProcessing(true);
const splitResult = await splitPdf(input, values.pageRanges);
setResult(splitResult);
} catch (error) {
throw new Error('Error splitting PDF:' + error);
} finally {
setIsProcessing(false);
}
};
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialValues}
compute={compute}
exampleCards={exampleCards}
inputComponent={
<ToolPdfInput
value={input}
onChange={setInput}
accept={['application/pdf']}
title={'Input PDF'}
/>
}
resultComponent={
<ToolFileResult
title={'Output PDF with selected pages'}
value={result}
extension={'pdf'}
loading={isProcessing}
loadingText={'Extracting pages'}
/>
}
getGroups={({ values, updateField }) => [
{
title: 'Page Selection',
component: (
<Box>
{totalPages > 0 && (
<Typography variant="body2" sx={{ mb: 1 }}>
PDF has {totalPages} page{totalPages !== 1 ? 's' : ''}
</Typography>
)}
<TextFieldWithDesc
value={values.pageRanges}
onOwnChange={(val) => {
updateField('pageRanges', val);
}}
description={
'Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)'
}
placeholder={'e.g., 1,5-8'}
/>
{pageRangePreview && (
<Typography
variant="body2"
sx={{ mt: 1, color: 'primary.main' }}
>
{pageRangePreview}
</Typography>
)}
</Box>
)
}
]}
onValuesChange={onValuesChange}
toolInfo={{
title: 'How to Use the Split PDF Tool',
description: `This tool allows you to extract specific pages from a PDF document. You can specify individual page numbers (e.g., 1,3,5) or page ranges (e.g., 2-6) or a combination of both (e.g., 1,3-5,8).
Leave the page ranges field empty to include all pages from the PDF.
Examples:
- "1,5,9" extracts pages 1, 5, and 9
- "1-5" extracts pages 1 through 5
- "1,3-5,8-10" extracts pages 1, 3, 4, 5, 8, 9, and 10`
}}
/>
);
}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const meta = defineTool('pdf', {
name: 'Split PDF',
shortDescription: 'Extract specific pages from a PDF file',
description:
'Extract specific pages from a PDF file using page numbers or ranges (e.g., 1,5-8)',
icon: 'mdi:file-pdf-box',
component: lazy(() => import('./index')),
keywords: ['pdf', 'split', 'extract', 'pages', 'range', 'document'],
path: 'split-pdf'
});

View File

@@ -0,0 +1,43 @@
import { parsePageRanges } from './service';
describe('parsePageRanges', () => {
test('should return all pages when input is empty', () => {
expect(parsePageRanges('', 5)).toEqual([1, 2, 3, 4, 5]);
});
test('should parse single page numbers', () => {
expect(parsePageRanges('1,3,5', 5)).toEqual([1, 3, 5]);
});
test('should parse page ranges', () => {
expect(parsePageRanges('2-4', 5)).toEqual([2, 3, 4]);
});
test('should parse mixed page numbers and ranges', () => {
expect(parsePageRanges('1,3-5', 5)).toEqual([1, 3, 4, 5]);
});
test('should handle whitespace', () => {
expect(parsePageRanges(' 1, 3 - 5 ', 5)).toEqual([1, 3, 4, 5]);
});
test('should ignore invalid page numbers', () => {
expect(parsePageRanges('1,a,3', 5)).toEqual([1, 3]);
});
test('should ignore out-of-range page numbers', () => {
expect(parsePageRanges('1,6,3', 5)).toEqual([1, 3]);
});
test('should limit ranges to valid pages', () => {
expect(parsePageRanges('0-6', 5)).toEqual([1, 2, 3, 4, 5]);
});
test('should handle reversed ranges', () => {
expect(parsePageRanges('4-2', 5)).toEqual([2, 3, 4]);
});
test('should remove duplicates', () => {
expect(parsePageRanges('1,1,2,2-4,3', 5)).toEqual([1, 2, 3, 4]);
});
});

View File

@@ -0,0 +1,74 @@
import { PDFDocument } from 'pdf-lib';
/**
* Parses a page range string and returns an array of page numbers
* @param pageRangeStr String like "1,3-5,7" to extract pages 1, 3, 4, 5, and 7
* @param totalPages Total number of pages in the PDF
* @returns Array of page numbers to extract
*/
export function parsePageRanges(
pageRangeStr: string,
totalPages: number
): number[] {
if (!pageRangeStr.trim()) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pageNumbers = new Set<number>();
const ranges = pageRangeStr.split(',');
for (const range of ranges) {
const trimmedRange = range.trim();
if (trimmedRange.includes('-')) {
const [start, end] = trimmedRange.split('-').map(Number);
if (!isNaN(start) && !isNaN(end)) {
// Handle both forward and reversed ranges
const normalizedStart = Math.min(start, end);
const normalizedEnd = Math.max(start, end);
for (
let i = Math.max(1, normalizedStart);
i <= Math.min(totalPages, normalizedEnd);
i++
) {
pageNumbers.add(i);
}
}
} else {
const pageNum = parseInt(trimmedRange, 10);
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
pageNumbers.add(pageNum);
}
}
}
return [...pageNumbers].sort((a, b) => a - b);
}
/**
* Splits a PDF file based on specified page ranges
* @param pdfFile The input PDF file
* @param pageRanges String specifying which pages to extract (e.g., "1,3-5,7")
* @returns Promise resolving to a new PDF file with only the selected pages
*/
export async function splitPdf(
pdfFile: File,
pageRanges: string
): Promise<File> {
const arrayBuffer = await pdfFile.arrayBuffer();
const sourcePdf = await PDFDocument.load(arrayBuffer);
const totalPages = sourcePdf.getPageCount();
const pagesToExtract = parsePageRanges(pageRanges, totalPages);
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(
sourcePdf,
pagesToExtract.map((pageNum) => pageNum - 1)
);
copiedPages.forEach((page) => newPdf.addPage(page));
const newPdfBytes = await newPdf.save();
const newFileName = pdfFile.name.replace('.pdf', '-extracted.pdf');
return new File([newPdfBytes], newFileName, { type: 'application/pdf' });
}

View File

@@ -22,7 +22,8 @@ export type ToolCategory =
| 'list' | 'list'
| 'json' | 'json'
| 'csv' | 'csv'
| 'time'; | 'time'
| 'pdf';
export interface DefinedTool { export interface DefinedTool {
type: ToolCategory; type: ToolCategory;

View File

@@ -10,6 +10,7 @@ import { jsonTools } from '../pages/tools/json';
import { csvTools } from '../pages/tools/csv'; import { csvTools } from '../pages/tools/csv';
import { timeTools } from '../pages/tools/time'; import { timeTools } from '../pages/tools/time';
import { IconifyIcon } from '@iconify/react'; import { IconifyIcon } from '@iconify/react';
import { pdfTools } from '../pages/tools/pdf';
export const tools: DefinedTool[] = [ export const tools: DefinedTool[] = [
...imageTools, ...imageTools,
@@ -19,7 +20,8 @@ export const tools: DefinedTool[] = [
...csvTools, ...csvTools,
...videoTools, ...videoTools,
...numberTools, ...numberTools,
...timeTools ...timeTools,
...pdfTools
]; ];
const categoriesConfig: { const categoriesConfig: {
type: ToolCategory; type: ToolCategory;
@@ -76,6 +78,12 @@ const categoriesConfig: {
value: value:
'Tools for working with videos extract frames from videos, create GIFs from videos, convert videos to different formats, and much more.' 'Tools for working with videos extract frames from videos, create GIFs from videos, convert videos to different formats, and much more.'
}, },
{
type: 'pdf',
icon: 'tabler:pdf',
value:
'Tools for working with PDF files - extract text from PDFs, convert PDFs to other formats, manipulate PDFs, and much more.'
},
{ {
type: 'time', type: 'time',
icon: 'fluent-mdl2:date-time', icon: 'fluent-mdl2:date-time',