diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 8e88558..53a0e44 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,10 +4,13 @@
-
+
+
+
-
+
+
@@ -124,15 +127,16 @@
"Vitest.mergeText.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.parsePageRanges.executor": "Run",
"Vitest.removeDuplicateLines function.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.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
"Vitest.replaceText function.executor": "Run",
- "git-widget-placeholder": "main",
+ "git-widget-placeholder": "split-pdf",
"ignore.virus.scanning.warn.message": "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.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
@@ -166,11 +170,11 @@
+
-
@@ -180,7 +184,7 @@
-
+
@@ -194,6 +198,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -224,16 +241,6 @@
-
-
-
-
-
-
-
-
-
-
@@ -245,19 +252,19 @@
-
+
+
-
@@ -342,14 +349,6 @@
-
-
- 1740491274739
-
-
-
- 1740491274739
-
1740491737480
@@ -734,7 +733,15 @@
1742961898820
-
+
+
+ 1742967844908
+
+
+
+ 1742967844908
+
+
@@ -781,7 +788,6 @@
-
@@ -806,7 +812,8 @@
-
+
+
diff --git a/package-lock.json b/package-lock.json
index 95e7f37..d23e3e9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"morsee": "^1.0.9",
"notistack": "^3.0.1",
"omggif": "^1.0.10",
+ "pdf-lib": "^1.17.1",
"playwright": "^1.45.0",
"rc-slider": "^11.1.8",
"react": "^18.3.1",
@@ -2326,6 +2327,24 @@
"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": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -8255,6 +8274,24 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
diff --git a/package.json b/package.json
index 2bd110c..d97f8e3 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"morsee": "^1.0.9",
"notistack": "^3.0.1",
"omggif": "^1.0.10",
+ "pdf-lib": "^1.17.1",
"playwright": "^1.45.0",
"rc-slider": "^11.1.8",
"react": "^18.3.1",
diff --git a/src/components/ToolContent.tsx b/src/components/ToolContent.tsx
index a1aa62c..2e3a6b6 100644
--- a/src/components/ToolContent.tsx
+++ b/src/components/ToolContent.tsx
@@ -1,4 +1,4 @@
-import React, { ReactNode, useContext } from 'react';
+import React, { ReactNode, useContext, useEffect } from 'react';
import { Box } from '@mui/material';
import { Formik, FormikValues, useFormikContext } from 'formik';
import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions';
@@ -13,10 +13,12 @@ import { CustomSnackBarContext } from '../contexts/CustomSnackBarContext';
const FormikListenerComponent = ({
input,
- compute
+ compute,
+ onValuesChange
}: {
input: any;
compute: (optionsValues: T, input: any) => void;
+ onValuesChange?: (values: T) => void;
}) => {
const { values } = useFormikContext();
const { showSnackBar } = useContext(CustomSnackBarContext);
@@ -30,40 +32,31 @@ const FormikListenerComponent = ({
}
}, [values, input, showSnackBar]);
+ useEffect(() => {
+ onValuesChange?.(values);
+ }, [onValuesChange, values]);
return null; // This component doesn't render anything
};
interface ToolContentProps extends ToolComponentProps {
- // Input/Output components
inputComponent?: ReactNode;
resultComponent: ReactNode;
-
renderCustomInput?: (
values: T,
setFieldValue: (fieldName: string, value: any) => void
) => ReactNode;
-
- // Tool options
initialValues: T;
getGroups: GetGroupsType | null;
-
- // Computation function
compute: (optionsValues: T, input: I) => void;
-
- // Tool info (optional)
toolInfo?: {
title: string;
description?: string;
};
-
- // Input value to pass to the compute function
input?: I;
-
exampleCards?: CardExampleType[];
setInput?: React.Dispatch>;
-
- // Validation schema (optional)
validationSchema?: any;
+ onValuesChange?: (values: T) => void;
}
export default function ToolContent({
@@ -78,7 +71,8 @@ export default function ToolContent({
input,
setInput,
validationSchema,
- renderCustomInput
+ renderCustomInput,
+ onValuesChange
}: ToolContentProps) {
return (
@@ -98,7 +92,11 @@ export default function ToolContent({
}
result={resultComponent}
/>
- compute={compute} input={input} />
+
+ compute={compute}
+ input={input}
+ onValuesChange={onValuesChange}
+ />
{toolInfo && toolInfo.title && toolInfo.description && (
diff --git a/src/components/input/BaseFileInput.tsx b/src/components/input/BaseFileInput.tsx
index 94314f7..66c1241 100644
--- a/src/components/input/BaseFileInput.tsx
+++ b/src/components/input/BaseFileInput.tsx
@@ -14,7 +14,7 @@ import greyPattern from '@assets/grey-pattern.png';
interface BaseFileInputComponentProps extends BaseFileInputProps {
children: (props: { preview: string | undefined }) => ReactNode;
- type: 'image' | 'video' | 'audio';
+ type: 'image' | 'video' | 'audio' | 'pdf';
}
export default function BaseFileInput({
diff --git a/src/components/input/ToolPdfInput.tsx b/src/components/input/ToolPdfInput.tsx
new file mode 100644
index 0000000..f236283
--- /dev/null
+++ b/src/components/input/ToolPdfInput.tsx
@@ -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(null);
+
+ return (
+
+ {({ preview }) => (
+
+ )}
+
+ );
+}
diff --git a/src/components/result/ToolFileResult.tsx b/src/components/result/ToolFileResult.tsx
index 140cce3..315ed63 100644
--- a/src/components/result/ToolFileResult.tsx
+++ b/src/components/result/ToolFileResult.tsx
@@ -63,12 +63,14 @@ export default function ToolFileResult({
}
};
+ type SupportedFileType = 'image' | 'video' | 'audio' | 'pdf' | 'unknown';
// Determine the file type based on MIME type
- const getFileType = () => {
+ const getFileType = (): SupportedFileType => {
if (!value) return 'unknown';
if (value.type.startsWith('image/')) return 'image';
if (value.type.startsWith('video/')) return 'video';
if (value.type.startsWith('audio/')) return 'audio';
+ if (value.type.startsWith('application/pdf')) return 'pdf';
return 'unknown';
};
@@ -135,6 +137,14 @@ export default function ToolFileResult({
style={{ width: '100%', maxWidth: '500px' }}
/>
)}
+ {fileType === 'pdf' && (
+
+ )}
{fileType === 'unknown' && (
File processed successfully. Click download to save the
diff --git a/src/pages/tools/pdf/index.ts b/src/pages/tools/pdf/index.ts
new file mode 100644
index 0000000..e93a8c1
--- /dev/null
+++ b/src/pages/tools/pdf/index.ts
@@ -0,0 +1,4 @@
+import { meta as splitPdfMeta } from './split-pdf/meta';
+import { DefinedTool } from '@tools/defineTool';
+
+export const pdfTools: DefinedTool[] = [splitPdfMeta];
diff --git a/src/pages/tools/pdf/split-pdf/index.tsx b/src/pages/tools/pdf/split-pdf/index.tsx
new file mode 100644
index 0000000..2682949
--- /dev/null
+++ b/src/pages/tools/pdf/split-pdf/index.tsx
@@ -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[] = [
+ {
+ 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(null);
+ const [result, setResult] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [totalPages, setTotalPages] = useState(0);
+ const [pageRangePreview, setPageRangePreview] = useState('');
+
+ // 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 (
+
+ }
+ resultComponent={
+
+ }
+ getGroups={({ values, updateField }) => [
+ {
+ title: 'Page Selection',
+ component: (
+
+ {totalPages > 0 && (
+
+ PDF has {totalPages} page{totalPages !== 1 ? 's' : ''}
+
+ )}
+ {
+ 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 && (
+
+ {pageRangePreview}
+
+ )}
+
+ )
+ }
+ ]}
+ 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`
+ }}
+ />
+ );
+}
diff --git a/src/pages/tools/pdf/split-pdf/meta.ts b/src/pages/tools/pdf/split-pdf/meta.ts
new file mode 100644
index 0000000..9b5d033
--- /dev/null
+++ b/src/pages/tools/pdf/split-pdf/meta.ts
@@ -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'
+});
diff --git a/src/pages/tools/pdf/split-pdf/service.test.ts b/src/pages/tools/pdf/split-pdf/service.test.ts
new file mode 100644
index 0000000..44cf15a
--- /dev/null
+++ b/src/pages/tools/pdf/split-pdf/service.test.ts
@@ -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]);
+ });
+});
diff --git a/src/pages/tools/pdf/split-pdf/service.ts b/src/pages/tools/pdf/split-pdf/service.ts
new file mode 100644
index 0000000..7c9dfae
--- /dev/null
+++ b/src/pages/tools/pdf/split-pdf/service.ts
@@ -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();
+ 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 {
+ 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' });
+}
diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx
index 0ba3a66..e32b940 100644
--- a/src/tools/defineTool.tsx
+++ b/src/tools/defineTool.tsx
@@ -22,7 +22,8 @@ export type ToolCategory =
| 'list'
| 'json'
| 'csv'
- | 'time';
+ | 'time'
+ | 'pdf';
export interface DefinedTool {
type: ToolCategory;
diff --git a/src/tools/index.ts b/src/tools/index.ts
index e3156c9..57596f3 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -10,6 +10,7 @@ import { jsonTools } from '../pages/tools/json';
import { csvTools } from '../pages/tools/csv';
import { timeTools } from '../pages/tools/time';
import { IconifyIcon } from '@iconify/react';
+import { pdfTools } from '../pages/tools/pdf';
export const tools: DefinedTool[] = [
...imageTools,
@@ -19,7 +20,8 @@ export const tools: DefinedTool[] = [
...csvTools,
...videoTools,
...numberTools,
- ...timeTools
+ ...timeTools,
+ ...pdfTools
];
const categoriesConfig: {
type: ToolCategory;
@@ -76,6 +78,12 @@ const categoriesConfig: {
value:
'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',
icon: 'fluent-mdl2:date-time',