Merge pull request #102 from rohit267/feat/pdf-merge

[Feature] Merge PDFs
This commit is contained in:
Ibrahima G. Coulibaly
2025-05-13 22:52:51 +01:00
committed by GitHub
13 changed files with 4624 additions and 2432 deletions

282
.idea/workspace.xml generated
View File

@@ -4,13 +4,9 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="ci: on tag push, push docker images">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="fix: misc">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/input/ToolVideoInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/ToolVideoInput.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/compress/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/compress/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/loop/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/loop/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/rotate/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/rotate/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/trim/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/trim/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" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -27,7 +23,7 @@
<option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="chesterkxng" />
<entry key="$PROJECT_DIR$" value="main" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -46,94 +42,101 @@
&quot;state&quot;: &quot;OPEN&quot;
}
}</component>
<component name="GitHubPullRequestState">{
&quot;prStates&quot;: [
<component name="GitHubPullRequestState"><![CDATA[{
"prStates": [
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts51PkS9&quot;,
&quot;number&quot;: 22
"id": {
"id": "PR_kwDOMJIfts51PkS9",
"number": 22
},
&quot;lastSeen&quot;: 1741207144695
"lastSeen": 1741207144695
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6NiNYl&quot;,
&quot;number&quot;: 32
"id": {
"id": "PR_kwDOMJIfts6NiNYl",
"number": 32
},
&quot;lastSeen&quot;: 1741209723869
"lastSeen": 1741209723869
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Nheyd&quot;,
&quot;number&quot;: 31
"id": {
"id": "PR_kwDOMJIfts6Nheyd",
"number": 31
},
&quot;lastSeen&quot;: 1741213371410
"lastSeen": 1741213371410
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6NmRBs&quot;,
&quot;number&quot;: 33
"id": {
"id": "PR_kwDOMJIfts6NmRBs",
"number": 33
},
&quot;lastSeen&quot;: 1741282429036
"lastSeen": 1741282429036
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts5zyFTs&quot;,
&quot;number&quot;: 15
"id": {
"id": "PR_kwDOMJIfts5zyFTs",
"number": 15
},
&quot;lastSeen&quot;: 1741535540953
"lastSeen": 1741535540953
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QQB3c&quot;,
&quot;number&quot;: 59
"id": {
"id": "PR_kwDOMJIfts6QQB3c",
"number": 59
},
&quot;lastSeen&quot;: 1743018960900
"lastSeen": 1743018960900
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QMPEg&quot;,
&quot;number&quot;: 58
"id": {
"id": "PR_kwDOMJIfts6QMPEg",
"number": 58
},
&quot;lastSeen&quot;: 1743019452983
"lastSeen": 1743019452983
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QZvRI&quot;,
&quot;number&quot;: 61
"id": {
"id": "PR_kwDOMJIfts6QZvRI",
"number": 61
},
&quot;lastSeen&quot;: 1743103196866
"lastSeen": 1743103196866
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QqPrQ&quot;,
&quot;number&quot;: 73
"id": {
"id": "PR_kwDOMJIfts6QqPrQ",
"number": 73
},
&quot;lastSeen&quot;: 1743265865001
"lastSeen": 1743265865001
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Qp5nI&quot;,
&quot;number&quot;: 72
"id": {
"id": "PR_kwDOMJIfts6Qp5nI",
"number": 72
},
&quot;lastSeen&quot;: 1743338472110
"lastSeen": 1743338472110
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QsjlS&quot;,
&quot;number&quot;: 76
"id": {
"id": "PR_kwDOMJIfts6QsjlS",
"number": 76
},
&quot;lastSeen&quot;: 1743352150953
"lastSeen": 1743352150953
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Q0JBe&quot;,
&quot;number&quot;: 82
"id": {
"id": "PR_kwDOMJIfts6Q0JBe",
"number": 82
},
&quot;lastSeen&quot;: 1743470267269
"lastSeen": 1743470267269
},
{
"id": {
"id": "PR_kwDOMJIfts6UE9-x",
"number": 102
},
"lastSeen": 1747171977348
}
]
}</component>
}]]></component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/iib0011/omni-tools.git&quot;,
@@ -162,56 +165,56 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ASKED_ADD_EXTERNAL_FILES&quot;: &quot;true&quot;,
&quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;Docker.Dockerfile build.executor&quot;: &quot;Run&quot;,
&quot;Docker.Dockerfile.executor&quot;: &quot;Run&quot;,
&quot;Playwright.Create transparent PNG.should make png color transparent.executor&quot;: &quot;Run&quot;,
&quot;Playwright.JoinText Component.executor&quot;: &quot;Run&quot;,
&quot;Playwright.JoinText Component.should merge text pieces with specified join character.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;Vitest.compute function (1).executor&quot;: &quot;Run&quot;,
&quot;Vitest.compute function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor&quot;: &quot;Run&quot;,
&quot;Vitest.parsePageRanges.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.newlines option.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor&quot;: &quot;Run&quot;,
&quot;Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor&quot;: &quot;Run&quot;,
&quot;Vitest.replaceText function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.timeBetweenDates.executor&quot;: &quot;Run&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/Ibrahima/IdeaProjects/omni-tools/src&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.build.executor&quot;: &quot;Run&quot;,
&quot;npm.dev.executor&quot;: &quot;Run&quot;,
&quot;npm.lint.executor&quot;: &quot;Run&quot;,
&quot;npm.prebuild.executor&quot;: &quot;Run&quot;,
&quot;npm.script:create:tool.executor&quot;: &quot;Run&quot;,
&quot;npm.test.executor&quot;: &quot;Run&quot;,
&quot;npm.test:e2e.executor&quot;: &quot;Run&quot;,
&quot;npm.test:e2e:run.executor&quot;: &quot;Run&quot;,
&quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier&quot;,
&quot;project.structure.last.edited&quot;: &quot;Problems&quot;,
&quot;project.structure.proportion&quot;: &quot;0.0&quot;,
&quot;project.structure.side.proportion&quot;: &quot;0.2&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;refactai_advanced_settings&quot;,
&quot;ts.external.directory.path&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ASKED_ADD_EXTERNAL_FILES": "true",
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
"Docker.Dockerfile build.executor": "Run",
"Docker.Dockerfile.executor": "Run",
"Playwright.Create transparent PNG.should make png color transparent.executor": "Run",
"Playwright.JoinText Component.executor": "Run",
"Playwright.JoinText Component.should merge text pieces with specified join character.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"Vitest.compute function (1).executor": "Run",
"Vitest.compute function.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, 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",
"Vitest.timeBetweenDates.executor": "Run",
"git-widget-placeholder": "#102 on fork/rohit267/feat/pdf-merge",
"ignore.virus.scanning.warn.message": "true",
"kotlin-language-version-configured": "true",
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"npm.build.executor": "Run",
"npm.dev.executor": "Run",
"npm.lint.executor": "Run",
"npm.prebuild.executor": "Run",
"npm.script:create:tool.executor": "Run",
"npm.test.executor": "Run",
"npm.test:e2e.executor": "Run",
"npm.test:e2e:run.executor": "Run",
"prettierjs.PrettierConfiguration.Package": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier",
"project.structure.last.edited": "Problems",
"project.structure.proportion": "0.0",
"project.structure.side.proportion": "0.2",
"settings.editor.selected.configurable": "refactai_advanced_settings",
"ts.external.directory.path": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
}
}</component>
}]]></component>
<component name="ReactDesignerToolWindowState">
<option name="myId2Visible">
<map>
@@ -416,31 +419,10 @@
<workItem from="1743782726563" duration="2444000" />
<workItem from="1743811558991" duration="1279000" />
<workItem from="1745523972292" duration="3000" />
<workItem from="1745687713234" duration="591000" />
</task>
<task id="LOCAL-00144" summary="feat: stringify json">
<option name="closed" value="true" />
<created>1741417920442</created>
<option name="number" value="00144" />
<option name="presentableId" value="LOCAL-00144" />
<option name="project" value="LOCAL" />
<updated>1741417920442</updated>
</task>
<task id="LOCAL-00145" summary="feat: arithmetic sequence">
<option name="closed" value="true" />
<created>1741419142510</created>
<option name="number" value="00145" />
<option name="presentableId" value="LOCAL-00145" />
<option name="project" value="LOCAL" />
<updated>1741419142510</updated>
</task>
<task id="LOCAL-00146" summary="style: tools height">
<option name="closed" value="true" />
<created>1741419188990</created>
<option name="number" value="00146" />
<option name="presentableId" value="LOCAL-00146" />
<option name="project" value="LOCAL" />
<updated>1741419188990</updated>
<workItem from="1745687713234" duration="1747000" />
<workItem from="1745775228478" duration="1221000" />
<workItem from="1745835676024" duration="68000" />
<workItem from="1747171958176" duration="1105000" />
</task>
<task id="LOCAL-00147" summary="chore: update meta">
<option name="closed" value="true" />
@@ -810,7 +792,31 @@
<option name="project" value="LOCAL" />
<updated>1743811980098</updated>
</task>
<option name="localTasksCounter" value="193" />
<task id="LOCAL-00193" summary="fix: add mkv to supported videos">
<option name="closed" value="true" />
<created>1745688369521</created>
<option name="number" value="00193" />
<option name="presentableId" value="LOCAL-00193" />
<option name="project" value="LOCAL" />
<updated>1745688369521</updated>
</task>
<task id="LOCAL-00194" summary="feat: drag and drop">
<option name="closed" value="true" />
<created>1745688866294</created>
<option name="number" value="00194" />
<option name="presentableId" value="LOCAL-00194" />
<option name="project" value="LOCAL" />
<updated>1745688866294</updated>
</task>
<task id="LOCAL-00195" summary="fix: misc">
<option name="closed" value="true" />
<created>1747172914927</created>
<option name="number" value="00195" />
<option name="presentableId" value="LOCAL-00195" />
<option name="project" value="LOCAL" />
<updated>1747172914927</updated>
</task>
<option name="localTasksCounter" value="196" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -857,10 +863,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="chore: result file name" />
<MESSAGE value="chore: text result extensions" />
<MESSAGE value="chore: show new tools in landing" />
<MESSAGE value="chore: zoom on hover" />
<MESSAGE value="refactor: time between dates" />
<MESSAGE value="fix: typos" />
<MESSAGE value="feat: compress video" />
@@ -882,7 +884,11 @@
<MESSAGE value="feat: image to text" />
<MESSAGE value="chore: hideCopy if video or audio" />
<MESSAGE value="chore: readme img and fix broken link" />
<option name="LAST_COMMIT_MESSAGE" value="chore: readme img and fix broken link" />
<MESSAGE value="fix: add mkv to supported videos" />
<MESSAGE value="feat: drag and drop" />
<MESSAGE value="Merge branch 'feat/pdf-merge' of git-rohit:rohit267/omni-tools into feat/pdf-merge" />
<MESSAGE value="fix: misc" />
<option name="LAST_COMMIT_MESSAGE" value="fix: misc" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

5683
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import {
import { globalInputHeight } from '../../config/uiConfig';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import greyPattern from '@assets/grey-pattern.png';
import { isArray } from 'lodash';
interface BaseFileInputComponentProps extends BaseFileInputProps {
children: (props: { preview: string | undefined }) => ReactNode;
@@ -32,14 +33,18 @@ export default function BaseFileInput({
const { showSnackBar } = useContext(CustomSnackBarContext);
useEffect(() => {
if (value) {
const objectUrl = createObjectURL(value);
try {
if (isArray(value)) {
const objectUrl = createObjectURL(value[0]);
setPreview(objectUrl);
return () => revokeObjectURL(objectUrl);
} else {
setPreview(null);
}
} catch (error) {
console.error('Error previewing file:', error);
}
}, [value]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -52,9 +57,9 @@ export default function BaseFileInput({
};
const handleCopy = () => {
if (value) {
const blob = new Blob([value], { type: value.type });
const clipboardItem = new ClipboardItem({ [value.type]: blob });
if (isArray(value)) {
const blob = new Blob([value[0]], { type: value[0].type });
const clipboardItem = new ClipboardItem({ [value[0].type]: blob });
navigator.clipboard
.write([clipboardItem])
@@ -65,6 +70,11 @@ export default function BaseFileInput({
}
};
function handleClear() {
// @ts-ignore
onChange(null);
}
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -206,13 +216,18 @@ export default function BaseFileInput({
</Box>
)}
</Box>
<InputFooter handleCopy={handleCopy} handleImport={handleImportClick} />
<InputFooter
handleCopy={handleCopy}
handleImport={handleImportClick}
handleClear={handleClear}
/>
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple={false}
/>
</Box>
);

View File

@@ -2,23 +2,32 @@ import { Stack } from '@mui/material';
import Button from '@mui/material/Button';
import PublishIcon from '@mui/icons-material/Publish';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import React from 'react';
import ClearIcon from '@mui/icons-material/Clear';
export default function InputFooter({
handleImport,
handleCopy
handleCopy,
handleClear
}: {
handleImport: () => void;
handleCopy: () => void;
handleCopy?: () => void;
handleClear?: () => void;
}) {
return (
<Stack mt={1} direction={'row'} spacing={2}>
<Button onClick={handleImport} startIcon={<PublishIcon />}>
Import from file
</Button>
{handleCopy && (
<Button onClick={handleCopy} startIcon={<ContentPasteIcon />}>
Copy to clipboard
</Button>
)}
{handleClear && (
<Button onClick={handleClear} startIcon={<ClearIcon />}>
Clear
</Button>
)}
</Stack>
);
}

View File

@@ -0,0 +1,176 @@
import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import { isArray } from 'lodash';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
interface MultiPdfInputComponentProps {
accept: string[];
title?: string;
type: 'pdf';
value: MultiPdfInput[];
onChange: (file: MultiPdfInput[]) => void;
}
export interface MultiPdfInput {
file: File;
order: number;
}
export default function ToolMultiFileInput({
value,
onChange,
accept,
title,
type
}: MultiPdfInputComponentProps) {
const theme = useTheme();
const fileInputRef = useRef<HTMLInputElement>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files)
onChange([
...value,
...Array.from(files).map((file) => ({ file, order: value.length }))
]);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
function handleClear() {
onChange([]);
}
function fileNameTruncate(fileName: string) {
const maxLength = 10;
if (fileName.length > maxLength) {
return fileName.slice(0, maxLength) + '...';
}
return fileName;
}
const sortList = () => {
const list = [...value];
list.sort((a, b) => a.order - b.order);
onChange(list);
};
const reorderList = (sourceIndex: number, destinationIndex: number) => {
console.log(sourceIndex, destinationIndex);
if (destinationIndex === sourceIndex) {
return;
}
const list = [...value];
if (destinationIndex === 0) {
list[sourceIndex].order = list[0].order - 1;
sortList();
return;
}
if (destinationIndex === list.length - 1) {
list[sourceIndex].order = list[list.length - 1].order + 1;
sortList();
return;
}
if (destinationIndex < sourceIndex) {
list[sourceIndex].order =
(list[destinationIndex].order + list[destinationIndex - 1].order) / 2;
sortList();
return;
}
list[sourceIndex].order =
(list[destinationIndex].order + list[destinationIndex + 1].order) / 2;
sortList();
};
return (
<Box>
<InputHeader
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
/>
<Box
sx={{
width: '100%',
height: '300px',
border: value?.length ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'background.paper',
position: 'relative'
}}
>
<Box
width="100%"
height="100%"
sx={{
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
position: 'relative'
}}
>
{value?.length ? (
value.map((file, index) => (
<Box
key={index}
sx={{
margin: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '200px',
border: 1,
borderRadius: 1,
padding: 1
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PictureAsPdfIcon />
<Typography sx={{ marginLeft: 1 }}>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
</Box>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
No files selected
</Typography>
)}
</Box>
</Box>
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple={true}
/>
</Box>
);
}

View File

@@ -173,7 +173,9 @@ export default function ToolFileResult({
disabled={!value}
handleCopy={handleCopy}
handleDownload={handleDownload}
hideCopy={fileType === 'video' || fileType === 'audio'}
hideCopy={
fileType === 'video' || fileType === 'audio' || fileType === 'pdf'
}
/>
</Box>
);

View File

@@ -1,12 +1,14 @@
import { tool as pdfRotatePdf } from './rotate-pdf/meta';
import { meta as splitPdfMeta } from './split-pdf/meta';
import { meta as mergePdf } from './merge-pdf/meta';
import { DefinedTool } from '@tools/defineTool';
import { tool as compressPdfTool } from './compress-pdf/meta';
import { tool as protectPdfTool } from './protect-pdf/meta';
import { DefinedTool } from '@tools/defineTool';
export const pdfTools: DefinedTool[] = [
splitPdfMeta,
pdfRotatePdf,
compressPdfTool,
protectPdfTool
protectPdfTool,
mergePdf
];

View File

@@ -0,0 +1,66 @@
import { useState } from 'react';
import ToolFileResult from '@components/result/ToolFileResult';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { mergePdf } from './service';
import ToolMultiPdfInput, {
MultiPdfInput
} from '@components/input/ToolMultiplePdfInput';
export default function MergePdf({ title }: ToolComponentProps) {
const [input, setInput] = useState<MultiPdfInput[]>([]);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const compute = async (values: File[], input: MultiPdfInput[]) => {
if (input.length === 0) {
return;
}
try {
setIsProcessing(true);
const mergeResult = await mergePdf(input.map((i) => i.file));
setResult(mergeResult);
} catch (error) {
throw new Error('Error merging PDF:' + error);
} finally {
setIsProcessing(false);
}
};
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={input.map((i) => i.file)}
compute={compute}
inputComponent={
<ToolMultiPdfInput
value={input}
onChange={(pdfInputs) => {
setInput(pdfInputs);
}}
accept={['application/pdf']}
title={'Input PDF'}
type="pdf"
/>
}
getGroups={null}
resultComponent={
<ToolFileResult
title={'Output merged PDF'}
value={result}
extension={'pdf'}
loading={isProcessing}
loadingText={'Extracting pages'}
/>
}
toolInfo={{
title: 'How to Use the Merge PDF Tool?',
description: `This tool allows you to merge multiple PDF files into a single document.
To use the tool, simply upload the PDF files you want to merge. The tool will then combine all pages from the input files into a single PDF document.`
}}
/>
);
}

View File

@@ -0,0 +1,12 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const meta = defineTool('pdf', {
name: 'Merge PDF',
shortDescription: 'Merge multiple PDF files into a single document',
description: 'Combine multiple PDF files into a single document.',
icon: 'material-symbols-light:merge',
component: lazy(() => import('./index')),
keywords: ['pdf', 'merge', 'extract', 'pages', 'combine', 'document'],
path: 'merge-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,95 @@
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' });
}
/**
* Merges multiple PDF files into a single document
* @param pdfFiles Array of PDF files to merge
* @returns Promise resolving to a new PDF file with all pages combined
*/
export async function mergePdf(pdfFiles: File[]): Promise<File> {
const mergedPdf = await PDFDocument.create();
for (const pdfFile of pdfFiles) {
const arrayBuffer = await pdfFile.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach((page) => mergedPdf.addPage(page));
}
const mergedPdfBytes = await mergedPdf.save();
const mergedFileName = 'merged.pdf';
return new File([mergedPdfBytes], mergedFileName, {
type: 'application/pdf'
});
}

View File

@@ -10,6 +10,7 @@ import { InitialValuesType, RotationAngle } from './types';
import { parsePageRanges, rotatePdf } from './service';
import SimpleRadio from '@components/options/SimpleRadio';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { isArray } from 'lodash';
const initialValues: InitialValuesType = {
rotationAngle: 90,

View File

@@ -1,5 +1,5 @@
import { Box, Typography } from '@mui/material';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';