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