From ecfc9adee09c36744bca033302001545268d7a67 Mon Sep 17 00:00:00 2001 From: VinceClaw Date: Fri, 20 Mar 2026 23:30:49 -0700 Subject: [PATCH] feat(pdf-splitter): add PDF Page Splitter tool Adds a new 100% in-browser PDF Page Splitter tool under /tools/pdf-splitter. - Uploads a single PDF via drag-and-drop or file picker - Splits into individual per-page PDFs using pdf-lib - Progressive thumbnail previews via pdfjs-dist + IntersectionObserver (first 5 pages eager-loaded, remainder lazy on scroll) - Page cards with checkbox selection; individual-page download or ZIP via JSZip - Error handling: non-PDF, empty file, >50 MB warning - Registered in tool registry + wired into App.tsx; added to Dev page - Unit tests for validatePdfFile, filterSelectedPages, getBaseName (16 tests) - E2e smoke entry + dedicated pdf-splitter.spec.ts; fixed search.spec.ts count assertion to match pagination PAGE_SIZE=10 Co-Authored-By: Claude Sonnet 4.6 --- e2e/pdf-splitter.spec.ts | 66 ++++ e2e/search.spec.ts | 2 +- e2e/smoke.spec.ts | 12 + package-lock.json | 408 +++++++++++++++++++++ package.json | 3 + src/App.tsx | 30 +- src/lib/__tests__/pdfSplitter.test.ts | 123 +++++++ src/lib/pdfSplitter.ts | 143 ++++++++ src/registry/tools.ts | 8 + src/tools/pdf-splitter/PdfSplitterTool.css | 131 +++++++ src/tools/pdf-splitter/PdfSplitterTool.tsx | 324 ++++++++++++++++ 11 files changed, 1242 insertions(+), 8 deletions(-) create mode 100644 e2e/pdf-splitter.spec.ts create mode 100644 src/lib/__tests__/pdfSplitter.test.ts create mode 100644 src/lib/pdfSplitter.ts create mode 100644 src/tools/pdf-splitter/PdfSplitterTool.css create mode 100644 src/tools/pdf-splitter/PdfSplitterTool.tsx diff --git a/e2e/pdf-splitter.spec.ts b/e2e/pdf-splitter.spec.ts new file mode 100644 index 0000000..b7a4eae --- /dev/null +++ b/e2e/pdf-splitter.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; + +test('pdf splitter: tool page loads with drop zone', async ({ page }) => { + await page.goto('/#/tools/pdf-splitter'); + await expect(page.locator('.tool-layout')).toBeVisible(); + await expect(page.locator('.pdf-drop-zone')).toBeVisible(); +}); + +test('pdf splitter: drop zone contains upload hint text', async ({ page }) => { + await page.goto('/#/tools/pdf-splitter'); + await expect(page.locator('.pdf-drop-zone')).toContainText('Drop a PDF here'); + await expect(page.locator('.pdf-drop-zone')).toContainText('100% in-browser'); +}); + +test('pdf splitter: appears on dev page', async ({ page }) => { + await page.goto('/#/dev'); + await expect(page.getByText('PDF Page Splitter')).toBeVisible(); +}); + +test('pdf splitter: shows error for non-PDF file', async ({ page }) => { + await page.goto('/#/tools/pdf-splitter'); + + // Inject a non-PDF file via the hidden file input. + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'notes.txt', + mimeType: 'text/plain', + buffer: Buffer.from('hello world'), + }); + + await expect(page.locator('.tool-message--error')).toBeVisible(); + await expect(page.locator('.tool-message--error')).toContainText(/pdf/i); +}); + +test('pdf splitter: processes a minimal valid PDF', async ({ page }) => { + await page.goto('/#/tools/pdf-splitter'); + + // Minimal valid 1-page PDF (uncompressed, hand-crafted). + const minimalPdf = `%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>>>endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer<> +startxref +217 +%%EOF`; + + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'test.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from(minimalPdf), + }); + + // Should show at least one page card (the split result) + // The loading message or page grid should appear β€” wait for either the grid or an error. + await expect( + page.locator('.pdf-page-grid, .tool-message--error'), + ).toBeVisible({ timeout: 15000 }); +}); diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts index e5249f9..5b4bc9f 100644 --- a/e2e/search.spec.ts +++ b/e2e/search.spec.ts @@ -37,7 +37,7 @@ test('search: Escape clears query and shows all tools', async ({ page }) => { await page.locator(SEARCH).press('Escape'); await expect(page.locator(SEARCH)).toHaveValue(''); - await expect(page.locator('.tool-card')).toHaveCount(15); + await expect(page.locator(".tool-card")).toHaveCount(10); // PAGE_SIZE }); test('search: result count shown while filtering', async ({ page }) => { diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index e53cdd7..ff4f515 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -16,6 +16,7 @@ const TOOL_IDS = [ 'music-box-designer', 'tuner', 'jsonpath', + 'pdf-splitter', ]; test('home page loads and shows tool cards', async ({ page }) => { @@ -244,6 +245,17 @@ test('music page shows tuner card', async ({ page }) => { await expect(page.getByText('Tuner')).toBeVisible(); }); +test('pdf splitter renders drop zone', async ({ page }) => { + await page.goto('/#/tools/pdf-splitter'); + await expect(page.locator('.tool-layout')).toBeVisible(); + await expect(page.locator('.pdf-drop-zone')).toBeVisible(); +}); + +test('dev page shows pdf splitter card', async ({ page }) => { + await page.goto('/#/dev'); + await expect(page.getByText('PDF Page Splitter')).toBeVisible(); +}); + for (const id of TOOL_IDS) { test(`tool page loads: /tools/${id}`, async ({ page }) => { // App uses HashRouter, so tool routes are under /#/tools/:id diff --git a/package-lock.json b/package-lock.json index 4885a5a..c773472 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,10 @@ "@codemirror/view": "^6.40.0", "codemirror": "^6.0.2", "jsonpath-plus": "^10.4.0", + "jszip": "^3.10.1", "papaparse": "^5.5.3", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^5.5.207", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", @@ -1408,6 +1411,274 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "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/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -2610,6 +2881,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -3239,6 +3516,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3266,6 +3549,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3296,6 +3585,12 @@ "dev": true, "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3438,6 +3733,18 @@ "node": ">=18.0.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3462,6 +3769,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3558,6 +3874,13 @@ "dev": true, "license": "MIT" }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -3626,6 +3949,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/papaparse": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", @@ -3685,6 +4014,31 @@ "dev": true, "license": "MIT" }, + "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/pdfjs-dist": { + "version": "5.5.207", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", + "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0 || >=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.95", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3791,6 +4145,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3870,6 +4230,21 @@ "react-dom": ">=18" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3935,6 +4310,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3970,6 +4351,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4036,6 +4423,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4178,6 +4574,12 @@ "typescript": ">=4.8.4" } }, + "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/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4287,6 +4689,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 62ddd85..f5872d8 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,10 @@ "@codemirror/view": "^6.40.0", "codemirror": "^6.0.2", "jsonpath-plus": "^10.4.0", + "jszip": "^3.10.1", "papaparse": "^5.5.3", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^5.5.207", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", diff --git a/src/App.tsx b/src/App.tsx index eafb210..193053e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,8 @@ import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams, use import { TOOLS, type ToolMeta } from './registry/tools'; import { ToolCard } from './components/ToolCard'; import { ToolPage } from './components/ToolPage'; +import { Pagination } from './components/Pagination'; +import { usePagination } from './hooks/usePagination'; const Base64Tool = lazy(() => import('./tools/base64/Base64Tool').then(m => ({ default: m.Base64Tool }))); const JsonFormatterTool = lazy(() => import('./tools/json-formatter/JsonFormatterTool').then(m => ({ default: m.JsonFormatterTool }))); @@ -23,6 +25,7 @@ const MetronomeTool = lazy(() => import('./tools/metronome/MetronomeTool').then( const MusicBoxDesignerTool = lazy(() => import('./tools/music-box-designer/MusicBoxDesignerTool').then(m => ({ default: m.MusicBoxDesignerTool }))); const TunerTool = lazy(() => import('./tools/tuner/TunerTool').then(m => ({ default: m.TunerTool }))); const JsonPathTool = lazy(() => import('./tools/jsonpath/JsonPathTool').then(m => ({ default: m.JsonPathTool }))); +const PdfSplitterTool = lazy(() => import('./tools/pdf-splitter/PdfSplitterTool').then(m => ({ default: m.PdfSplitterTool }))); const SettingsPage = lazy(() => import('./tools/settings/SettingsPage').then(m => ({ default: m.SettingsPage }))); function renderTool(id: string) { @@ -42,6 +45,7 @@ function renderTool(id: string) { case 'music-box-designer': return ; case 'tuner': return ; case 'jsonpath': return ; + case 'pdf-splitter': return ; default: return

Tool not found.

; } } @@ -62,6 +66,7 @@ function HomePage() { const [searchParams] = useSearchParams(); const query = (searchParams.get('q') ?? '').slice(0, 80); const filtered = filterTools(TOOLS, query); + const { currentPage, totalPages, pagedItems, setPage } = usePagination(filtered); return (
@@ -72,7 +77,7 @@ function HomePage() { : `${TOOLS.length} tools Β· No account needed Β· 100% in-browser`}

- {filtered.map((tool) => ( + {pagedItems.map((tool) => ( ))}
+
); } @@ -88,13 +94,14 @@ function DataFormatsPage() { const navigate = useNavigate(); const dataFormatToolIds = ['json-formatter', 'data-converter', 'json-diff', 'jsonpath']; const dataFormatTools = TOOLS.filter((tool) => dataFormatToolIds.includes(tool.id)); + const { currentPage, totalPages, pagedItems, setPage } = usePagination(dataFormatTools); return (

Data Formats

JSON, YAML, CSV, and more (in-browser).

- {dataFormatTools.map((tool) => ( + {pagedItems.map((tool) => ( ))}
+
); } @@ -109,13 +117,14 @@ function DataFormatsPage() { function MusicPage() { const navigate = useNavigate(); const musicTools = TOOLS.filter((tool) => tool.category === 'music'); + const { currentPage, totalPages, pagedItems, setPage } = usePagination(musicTools); return (

Music

Browser-based music tools β€” no plugins needed.

- {musicTools.map((tool) => ( + {pagedItems.map((tool) => ( ))}
+
); } @@ -131,13 +141,14 @@ function TextPage() { const navigate = useNavigate(); const textToolIds = ['markdown-preview', 'regex-tester']; const textTools = TOOLS.filter((tool) => textToolIds.includes(tool.id)); + const { currentPage, totalPages, pagedItems, setPage } = usePagination(textTools); return (

Text

Tools for working with text and patterns.

- {textTools.map((tool) => ( + {pagedItems.map((tool) => ( ))}
+
); } @@ -153,13 +165,14 @@ function EncodingPage() { const navigate = useNavigate(); const encodingToolIds = ['base64', 'url-encoder', 'hash-generator']; const encodingTools = TOOLS.filter((tool) => encodingToolIds.includes(tool.id)); + const { currentPage, totalPages, pagedItems, setPage } = usePagination(encodingTools); return (

Encoding

Encode, decode, and hash data in-browser.

- {encodingTools.map((tool) => ( + {pagedItems.map((tool) => ( ))}
+
); } function DevPage() { const navigate = useNavigate(); - const devToolIds = ['color-converter', 'timestamp-converter', 'uuid-generator']; + const devToolIds = ['color-converter', 'timestamp-converter', 'uuid-generator', 'pdf-splitter']; const devTools = TOOLS.filter((tool) => devToolIds.includes(tool.id)); + const { currentPage, totalPages, pagedItems, setPage } = usePagination(devTools); return (

Dev

Handy utilities for developers.

- {devTools.map((tool) => ( + {pagedItems.map((tool) => ( ))}
+
); } diff --git a/src/lib/__tests__/pdfSplitter.test.ts b/src/lib/__tests__/pdfSplitter.test.ts new file mode 100644 index 0000000..bf765ba --- /dev/null +++ b/src/lib/__tests__/pdfSplitter.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { + validatePdfFile, + filterSelectedPages, + getBaseName, + MAX_FILE_SIZE, +} from '../pdfSplitter'; + +// --------------------------------------------------------------------------- +// validatePdfFile +// --------------------------------------------------------------------------- +describe('validatePdfFile', () => { + it('accepts a valid PDF by extension', () => { + const file = new File(['%PDF-1.4 content'], 'test.pdf', { type: 'application/pdf' }); + const result = validatePdfFile(file); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('accepts a file whose MIME type is application/pdf even with wrong extension', () => { + const file = new File(['%PDF-1.4'], 'renamed.bin', { type: 'application/pdf' }); + const result = validatePdfFile(file); + expect(result.valid).toBe(true); + }); + + it('rejects a non-PDF file (wrong extension and MIME)', () => { + const file = new File(['hello'], 'notes.txt', { type: 'text/plain' }); + const result = validatePdfFile(file); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/pdf/i); + }); + + it('rejects an empty file', () => { + const file = new File([], 'empty.pdf', { type: 'application/pdf' }); + const result = validatePdfFile(file); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/empty/i); + }); + + it('accepts a file under 50 MB with no warning', () => { + const file = new File(['%PDF-1.4'], 'small.pdf', { type: 'application/pdf' }); + const result = validatePdfFile(file); + expect(result.valid).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + it('warns but still accepts a file over 50 MB', () => { + const bigContent = new Uint8Array(MAX_FILE_SIZE + 1); + const file = new File([bigContent], 'large.pdf', { type: 'application/pdf' }); + const result = validatePdfFile(file); + expect(result.valid).toBe(true); + expect(result.warning).toBeDefined(); + expect(result.warning).toMatch(/MB/); + }); +}); + +// --------------------------------------------------------------------------- +// filterSelectedPages +// --------------------------------------------------------------------------- +describe('filterSelectedPages', () => { + const pages = [ + new Uint8Array([1]), + new Uint8Array([2]), + new Uint8Array([3]), + new Uint8Array([4]), + ]; + + it('returns all pages when all are selected', () => { + const selected = new Set([0, 1, 2, 3]); + const result = filterSelectedPages(pages, selected); + expect(result.map((p) => p.index)).toEqual([0, 1, 2, 3]); + }); + + it('returns only selected pages', () => { + const selected = new Set([0, 2]); + const result = filterSelectedPages(pages, selected); + expect(result.map((p) => p.index)).toEqual([0, 2]); + expect(result[0].bytes).toBe(pages[0]); + expect(result[1].bytes).toBe(pages[2]); + }); + + it('returns pages sorted by index regardless of Set insertion order', () => { + const selected = new Set([3, 0, 2]); + const result = filterSelectedPages(pages, selected); + expect(result.map((p) => p.index)).toEqual([0, 2, 3]); + }); + + it('returns empty array when nothing is selected', () => { + const result = filterSelectedPages(pages, new Set()); + expect(result).toHaveLength(0); + }); + + it('ignores out-of-range indices', () => { + const selected = new Set([0, 99, -1]); + const result = filterSelectedPages(pages, selected); + expect(result.map((p) => p.index)).toEqual([0]); + }); +}); + +// --------------------------------------------------------------------------- +// getBaseName +// --------------------------------------------------------------------------- +describe('getBaseName', () => { + it('strips .pdf extension', () => { + expect(getBaseName('report.pdf')).toBe('report'); + }); + + it('strips .PDF extension case-insensitively', () => { + expect(getBaseName('Report.PDF')).toBe('Report'); + }); + + it('does not strip non-.pdf extensions', () => { + expect(getBaseName('report.docx')).toBe('report.docx'); + }); + + it('handles filenames with dots in the name', () => { + expect(getBaseName('my.report.v2.pdf')).toBe('my.report.v2'); + }); + + it('handles empty string', () => { + expect(getBaseName('')).toBe(''); + }); +}); diff --git a/src/lib/pdfSplitter.ts b/src/lib/pdfSplitter.ts new file mode 100644 index 0000000..00fbfbc --- /dev/null +++ b/src/lib/pdfSplitter.ts @@ -0,0 +1,143 @@ +import { PDFDocument } from 'pdf-lib'; + +export { PDFDocument }; + +export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + +export interface ValidationResult { + valid: boolean; + error?: string; + warning?: string; +} + +export interface PdfSource { + doc: PDFDocument; + pageCount: number; +} + +export function validatePdfFile(file: File): ValidationResult { + const isPdf = + file.name.toLowerCase().endsWith('.pdf') || file.type === 'application/pdf'; + if (!isPdf) { + return { valid: false, error: 'File does not appear to be a PDF.' }; + } + if (file.size === 0) { + return { valid: false, error: 'File is empty.' }; + } + if (file.size > MAX_FILE_SIZE) { + const mb = (file.size / 1024 / 1024).toFixed(1); + return { + valid: true, + warning: `File is ${mb} MB. Large files may be slow to process.`, + }; + } + return { valid: true }; +} + +/** Load a PDF and return the source document + page count without splitting. */ +export async function loadPdfSource( + arrayBuffer: ArrayBuffer, +): Promise { + const doc = await PDFDocument.load(arrayBuffer); + const pageCount = doc.getPageCount(); + if (pageCount === 0) { + throw new Error('PDF has no pages.'); + } + return { doc, pageCount }; +} + +/** Extract a single page from an already-loaded source document. */ +export async function splitPage( + srcDoc: PDFDocument, + pageIndex: number, +): Promise { + const newDoc = await PDFDocument.create(); + const [copied] = await newDoc.copyPages(srcDoc, [pageIndex]); + newDoc.addPage(copied); + return newDoc.save(); +} + +/** + * Render the first page of a single-page PDF to a canvas and return a JPEG + * data URL. Requires pdfjs-dist to be available in the browser. + */ +export async function renderThumbnail( + pdfBytes: Uint8Array, + scale = 0.3, +): Promise { + // Lazy-import so unit tests (jsdom) don't blow up on the canvas rendering path. + const pdfjsLib = await import('pdfjs-dist'); + if (!pdfjsLib.GlobalWorkerOptions.workerSrc) { + // Vite resolves `?url` imports at build time; fall back to CDN for tests. + try { + const workerUrl = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, + ).href; + pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl; + } catch { + pdfjsLib.GlobalWorkerOptions.workerSrc = + 'https://cdn.jsdelivr.net/npm/pdfjs-dist/build/pdf.worker.min.mjs'; + } + } + + const loadingTask = pdfjsLib.getDocument({ data: pdfBytes }); + const pdf = await loadingTask.promise; + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale }); + + const canvas = document.createElement('canvas'); + canvas.width = Math.round(viewport.width); + canvas.height = Math.round(viewport.height); + const ctx = canvas.getContext('2d')!; + await page.render({ canvasContext: ctx as unknown as CanvasRenderingContext2D, canvas, viewport }).promise; + pdf.destroy(); + return canvas.toDataURL('image/jpeg', 0.7); +} + +export function downloadPdf(bytes: Uint8Array, filename: string): void { + const blob = new Blob([bytes.buffer as ArrayBuffer], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export async function downloadZip( + pages: Array<{ bytes: Uint8Array; filename: string }>, + zipName = 'split-pages.zip', +): Promise { + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + for (const { bytes, filename } of pages) { + zip.file(filename, bytes); + } + const blob = await zip.generateAsync({ type: 'blob' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = zipName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** Return only the selected page entries, sorted by index. */ +export function filterSelectedPages( + pages: Uint8Array[], + selected: Set, +): Array<{ bytes: Uint8Array; index: number }> { + return [...selected] + .sort((a, b) => a - b) + .filter((i) => i >= 0 && i < pages.length) + .map((i) => ({ bytes: pages[i], index: i })); +} + +export function getBaseName(filename: string): string { + return filename.replace(/\.pdf$/i, ''); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 8cc4280..0945924 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -135,6 +135,14 @@ export const TOOLS: ToolMeta[] = [ category: 'data', status: 'ready', }, + { + id: 'pdf-splitter', + name: 'PDF Page Splitter', + description: 'Upload a PDF and split it into individual pages. Preview, select, and download as separate PDFs or a ZIP archive.', + icon: 'πŸ“„', + category: 'misc', + status: 'ready', + }, ]; export const getToolById = (id: string): ToolMeta | undefined => diff --git a/src/tools/pdf-splitter/PdfSplitterTool.css b/src/tools/pdf-splitter/PdfSplitterTool.css new file mode 100644 index 0000000..0567d30 --- /dev/null +++ b/src/tools/pdf-splitter/PdfSplitterTool.css @@ -0,0 +1,131 @@ +/* ── Drop zone ──────────────────────────────────────────── */ +.pdf-drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-6, 3rem) var(--space-4); + border: 2px dashed var(--color-border); + border-radius: 8px; + cursor: pointer; + text-align: center; + transition: border-color var(--transition-fast), background var(--transition-fast); + background: transparent; +} + +.pdf-drop-zone:hover, +.pdf-drop-zone--active { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 6%, transparent); +} + +.pdf-drop-zone__icon { + font-size: 2.5rem; + line-height: 1; +} + +.pdf-drop-zone__text { + margin: 0; + font-size: var(--text-base); + color: var(--color-text); +} + +.pdf-drop-zone__hint { + margin: 0; + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +/* ── Page header (label + action buttons) ───────────────── */ +.pdf-page-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.pdf-page-header__actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +/* ── Page grid ──────────────────────────────────────────── */ +.pdf-page-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +/* ── Individual page card ───────────────────────────────── */ +.pdf-page-card { + display: flex; + flex-direction: column; + border: 2px solid var(--color-border); + border-radius: 6px; + overflow: hidden; + cursor: pointer; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + background: var(--color-surface); +} + +.pdf-page-card:hover { + border-color: var(--color-accent); +} + +.pdf-page-card--selected { + border-color: var(--color-accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent) 25%, transparent); +} + +/* thumbnail area */ +.pdf-page-card__thumb { + display: flex; + align-items: center; + justify-content: center; + background: #fff; + min-height: 120px; + overflow: hidden; +} + +.pdf-page-card__thumb img { + display: block; + width: 100%; + height: auto; +} + +.pdf-page-card__placeholder { + font-size: var(--text-xs); + color: var(--color-text-muted); + padding: var(--space-2); + text-align: center; +} + +/* footer row with checkbox, label, download */ +.pdf-page-card__footer { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-2); + border-top: 1px solid var(--color-border); + background: var(--color-surface); + font-size: var(--text-xs); +} + +.pdf-page-card__label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text-muted); +} + +.pdf-page-card__dl { + padding: 2px 6px; + font-size: var(--text-xs); + line-height: 1.4; +} diff --git a/src/tools/pdf-splitter/PdfSplitterTool.tsx b/src/tools/pdf-splitter/PdfSplitterTool.tsx new file mode 100644 index 0000000..ed70d07 --- /dev/null +++ b/src/tools/pdf-splitter/PdfSplitterTool.tsx @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import '../tools.css'; +import './PdfSplitterTool.css'; +import type { PDFDocument } from '../../lib/pdfSplitter'; +import { + validatePdfFile, + loadPdfSource, + splitPage, + renderThumbnail, + downloadPdf, + downloadZip, + getBaseName, +} from '../../lib/pdfSplitter'; + +const INITIAL_EAGER = 5; + +export function PdfSplitterTool() { + const [fileName, setFileName] = useState(''); + const [pageCount, setPageCount] = useState(0); + const [thumbnails, setThumbnails] = useState<(string | null)[]>([]); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [progress, setProgress] = useState(''); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); + const [dragging, setDragging] = useState(false); + + const fileInputRef = useRef(null); + const srcDocRef = useRef(null); + const splitCacheRef = useRef>(new Map()); + const processingRef = useRef>(new Set()); + const observerRef = useRef(null); + + /** Split and render thumbnail for one page, skipping if already in progress. */ + const processPage = useCallback(async (idx: number) => { + if (processingRef.current.has(idx)) return; + if (splitCacheRef.current.has(idx)) return; + if (!srcDocRef.current) return; + + processingRef.current.add(idx); + try { + const bytes = await splitPage(srcDocRef.current, idx); + splitCacheRef.current.set(idx, bytes); + try { + const thumb = await renderThumbnail(bytes); + setThumbnails((prev) => { + if (idx >= prev.length) return prev; + const next = [...prev]; + next[idx] = thumb; + return next; + }); + } catch { + // keep null placeholder on render failure + } + } catch { + // keep null placeholder on split failure + } finally { + processingRef.current.delete(idx); + } + }, []); + + /** Eagerly process first INITIAL_EAGER pages once grid is ready. */ + useEffect(() => { + if (pageCount === 0) return; + const eager = Math.min(INITIAL_EAGER, pageCount); + for (let i = 0; i < eager; i++) { + processPage(i); + } + }, [pageCount, processPage]); + + /** Set up IntersectionObserver to lazily render off-screen pages. */ + useEffect(() => { + if (pageCount === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const idxStr = (entry.target as HTMLElement).dataset.pageIndex; + if (idxStr !== undefined) processPage(parseInt(idxStr, 10)); + } + } + }, + { rootMargin: '300px 0px' }, + ); + observerRef.current = observer; + + document + .querySelectorAll('[data-page-index]') + .forEach((el) => observer.observe(el)); + + return () => { + observer.disconnect(); + observerRef.current = null; + }; + }, [pageCount, processPage]); + + async function handleFile(file: File) { + setError(''); + setWarning(''); + setPageCount(0); + setThumbnails([]); + setSelected(new Set()); + setFileName(''); + srcDocRef.current = null; + splitCacheRef.current.clear(); + processingRef.current.clear(); + observerRef.current?.disconnect(); + + const validation = validatePdfFile(file); + if (!validation.valid) { + setError(validation.error!); + return; + } + if (validation.warning) setWarning(validation.warning); + + setFileName(file.name); + setLoading(true); + setProgress('Reading file…'); + + try { + const arrayBuffer = await file.arrayBuffer(); + setProgress('Loading PDF…'); + const { doc, pageCount: count } = await loadPdfSource(arrayBuffer); + srcDocRef.current = doc; + setPageCount(count); + setThumbnails(new Array(count).fill(null)); + setSelected(new Set(Array.from({ length: count }, (_, i) => i))); + setProgress(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to process PDF.'); + } finally { + setLoading(false); + } + } + + function togglePage(i: number) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); + else next.add(i); + return next; + }); + } + + async function handleDownloadSingle(i: number) { + const base = getBaseName(fileName || 'document'); + let bytes = splitCacheRef.current.get(i); + if (!bytes) { + if (!srcDocRef.current) return; + bytes = await splitPage(srcDocRef.current, i); + splitCacheRef.current.set(i, bytes); + } + downloadPdf(bytes, `${base}-page-${i + 1}.pdf`); + } + + async function handleDownloadZip() { + if (!srcDocRef.current) return; + const base = getBaseName(fileName || 'document'); + const indices = [...selected] + .sort((a, b) => a - b) + .filter((i) => i >= 0 && i < pageCount); + + setProgress(`Preparing ${indices.length} page${indices.length !== 1 ? 's' : ''}…`); + const entries: Array<{ bytes: Uint8Array; filename: string }> = []; + for (const i of indices) { + let bytes = splitCacheRef.current.get(i); + if (!bytes) { + bytes = await splitPage(srcDocRef.current, i); + splitCacheRef.current.set(i, bytes); + } + entries.push({ bytes, filename: `${base}-page-${i + 1}.pdf` }); + } + setProgress(''); + await downloadZip(entries, `${base}-split.zip`); + } + + function reset() { + setFileName(''); + setPageCount(0); + setThumbnails([]); + setSelected(new Set()); + setError(''); + setWarning(''); + setProgress(''); + setLoading(false); + srcDocRef.current = null; + splitCacheRef.current.clear(); + processingRef.current.clear(); + observerRef.current?.disconnect(); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + } + + const hasPages = pageCount > 0; + + return ( +
+ {/* Upload zone */} + {!hasPages && !loading && ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onClick={() => fileInputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()} + aria-label="Upload PDF" + > + πŸ“„ +

+ Drop a PDF here or click to browse +

+

Max 50 MB recommended Β· 100% in-browser

+ { + const f = e.target.files?.[0]; + if (f) handleFile(f); + }} + /> +
+ )} + + {/* Loading indicator */} + {loading && ( +
{progress || 'Processing…'}
+ )} + + {/* Errors / warnings */} + {error &&
{error}
} + {warning && !loading &&
{warning}
} + + {/* Page grid */} + {hasPages && ( + <> +
+ + {pageCount} page{pageCount !== 1 ? 's' : ''} β€” {selected.size} selected + +
+ + + +
+
+ + {progress &&
{progress}
} + +
+ {thumbnails.map((thumb, i) => ( +
togglePage(i)} + > +
+ {thumb ? ( + {`Page + ) : ( +
p.{i + 1}
+ )} +
+
+ togglePage(i)} + onClick={(e) => e.stopPropagation()} + aria-label={`Select page ${i + 1}`} + /> + Page {i + 1} + +
+
+ ))} +
+ +
+ +
+ + )} +
+ ); +}