feat: split pdf

This commit is contained in:
Ibrahima G. Coulibaly
2025-03-26 05:43:59 +00:00
parent c0297a187d
commit e6f54a3f2b
11 changed files with 427 additions and 68 deletions

106
.idea/workspace.xml generated
View File

@@ -5,9 +5,17 @@
</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: background removal">
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/index.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/index.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/meta.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/service.test.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/service.ts" 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/result/ToolFileResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" 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$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/ToolContent.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolContent.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/tools/defineTool.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/tools/defineTool.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/tools/index.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/tools/index.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" />
@@ -107,54 +115,54 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"ASKED_ADD_EXTERNAL_FILES": "true", &quot;ASKED_ADD_EXTERNAL_FILES&quot;: &quot;true&quot;,
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true", &quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
"Docker.Dockerfile build.executor": "Run", &quot;Docker.Dockerfile build.executor&quot;: &quot;Run&quot;,
"Docker.Dockerfile.executor": "Run", &quot;Docker.Dockerfile.executor&quot;: &quot;Run&quot;,
"Playwright.Create transparent PNG.should make png color transparent.executor": "Run", &quot;Playwright.Create transparent PNG.should make png color transparent.executor&quot;: &quot;Run&quot;,
"Playwright.JoinText Component.executor": "Run", &quot;Playwright.JoinText Component.executor&quot;: &quot;Run&quot;,
"Playwright.JoinText Component.should merge text pieces with specified join character.executor": "Run", &quot;Playwright.JoinText Component.should merge text pieces with specified join character.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"Vitest.compute function (1).executor": "Run", &quot;Vitest.compute function (1).executor&quot;: &quot;Run&quot;,
"Vitest.compute function.executor": "Run", &quot;Vitest.compute function.executor&quot;: &quot;Run&quot;,
"Vitest.mergeText.executor": "Run", &quot;Vitest.mergeText.executor&quot;: &quot;Run&quot;,
"Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor": "Run", &quot;Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor&quot;: &quot;Run&quot;,
"Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor": "Run", &quot;Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor&quot;: &quot;Run&quot;,
"Vitest.removeDuplicateLines function.executor": "Run", &quot;Vitest.removeDuplicateLines function.executor&quot;: &quot;Run&quot;,
"Vitest.removeDuplicateLines function.newlines option.executor": "Run", &quot;Vitest.removeDuplicateLines function.newlines option.executor&quot;: &quot;Run&quot;,
"Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run", &quot;Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor&quot;: &quot;Run&quot;,
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run", &quot;Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor&quot;: &quot;Run&quot;,
"Vitest.replaceText function.executor": "Run", &quot;Vitest.replaceText function.executor&quot;: &quot;Run&quot;,
"git-widget-placeholder": "main", &quot;git-widget-placeholder&quot;: &quot;main&quot;,
"ignore.virus.scanning.warn.message": "true", &quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
"kotlin-language-version-configured": "true", &quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src/pages/tools/list/duplicate/index.tsx", &quot;last_opened_file_path&quot;: &quot;C:/Users/Ibrahima/IdeaProjects/omni-tools/src/pages/tools/list/duplicate/index.tsx&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"npm.build.executor": "Run", &quot;npm.build.executor&quot;: &quot;Run&quot;,
"npm.dev.executor": "Run", &quot;npm.dev.executor&quot;: &quot;Run&quot;,
"npm.lint.executor": "Run", &quot;npm.lint.executor&quot;: &quot;Run&quot;,
"npm.prebuild.executor": "Run", &quot;npm.prebuild.executor&quot;: &quot;Run&quot;,
"npm.script:create:tool.executor": "Run", &quot;npm.script:create:tool.executor&quot;: &quot;Run&quot;,
"npm.test.executor": "Run", &quot;npm.test.executor&quot;: &quot;Run&quot;,
"npm.test:e2e.executor": "Run", &quot;npm.test:e2e.executor&quot;: &quot;Run&quot;,
"npm.test:e2e:run.executor": "Run", &quot;npm.test:e2e:run.executor&quot;: &quot;Run&quot;,
"prettierjs.PrettierConfiguration.Package": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier", &quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier&quot;,
"project.structure.last.edited": "Problems", &quot;project.structure.last.edited&quot;: &quot;Problems&quot;,
"project.structure.proportion": "0.0", &quot;project.structure.proportion&quot;: &quot;0.0&quot;,
"project.structure.side.proportion": "0.2", &quot;project.structure.side.proportion&quot;: &quot;0.2&quot;,
"settings.editor.selected.configurable": "refactai_advanced_settings", &quot;settings.editor.selected.configurable&quot;: &quot;refactai_advanced_settings&quot;,
"ts.external.directory.path": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib", &quot;ts.external.directory.path&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></component> }</component>
<component name="ReactDesignerToolWindowState"> <component name="ReactDesignerToolWindowState">
<option name="myId2Visible"> <option name="myId2Visible">
<map> <map>

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

@@ -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,180 @@
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';
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={
<ToolFileInput
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,66 @@
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)) {
for (let i = Math.max(1, start); i <= Math.min(totalPages, end); 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',