diff --git a/docs/spec/BTB-026-pdf-page-splitter/BTB-026-architecture.md b/docs/spec/BTB-026-pdf-page-splitter/BTB-026-architecture.md
new file mode 100644
index 0000000..46027e0
--- /dev/null
+++ b/docs/spec/BTB-026-pdf-page-splitter/BTB-026-architecture.md
@@ -0,0 +1,61 @@
+---
+specId: BTB-026
+title: PDF Page Splitter
+owner: Vincent
+status: draft
+---
+
+# BTB-026-architecture.md
+
+## 1) Architecture design (OOP / module-level)
+
+### 1.1 Components and responsibilities
+
+#### PdfPageSplitterTool (main React component)
+- UI: file input, split button, progress indicator, auto-download
+- State: file, processing status, result bytes
+- Orchestration: file load → PdfProcessor → auto-download
+
+#### PdfProcessor (pdf-lib wrapper class)
+- loadPdf(file): Load PDF into pdf-lib Document
+- splitPages(document): Extract each page → cut vertically in half → reassemble as new pages
+- generateSplitPdf(): Create Document with split pages
+- savePdf(document): Convert to Uint8Array for download
+
+### 1.2 Data model / types
+
+```ts
+interface SplitResult {
+ originalPages: number;
+ splitPages: number; // should be originalPages * 2
+ splitPdfBytes: Uint8Array;
+}
+```
+
+### 1.3 Key flows
+
+#### Happy path
+```
+FileInput → PdfProcessor.loadPdf → PdfProcessor.splitPages → PdfProcessor.savePdf → FileSaver.download
+```
+
+#### Edge cases
+```
+Invalid PDF → PdfProcessor.loadPdf throws → user-friendly error
+Empty PDF → splitPages returns empty → reject download
+>100MB → early reject → no processing
+```
+
+### 1.4 Dependencies / constraints
+
+#### Dependencies
+- pdf-lib (PDF manipulation)
+- FileSaver.js (download)
+
+#### Constraints
+- Browser memory limits → reject >100MB
+- pdf-lib: no OCR/layout detection
+
+### 1.5 Compatibility notes
+
+N/A (new tool)
diff --git a/docs/spec/BTB-026-pdf-page-splitter/BTB-026-requirements.md b/docs/spec/BTB-026-pdf-page-splitter/BTB-026-requirements.md
new file mode 100644
index 0000000..bf57161
--- /dev/null
+++ b/docs/spec/BTB-026-pdf-page-splitter/BTB-026-requirements.md
@@ -0,0 +1,75 @@
+---
+specId: BTB-026
+title: PDF Page Splitter
+owner: Vincent
+status: draft
+---
+
+# BTB-026-requirements.md
+
+## 0) Context
+
+### Problem
+Users have double-page PDFs (two pages side-by-side per page) that need to be cut in half vertically and reassembled as single-page PDFs.
+
+### Users/persona
+Document processors (books/reports/scanned documents) needing single-page extraction for sharing/printing/processing.
+
+### Non-goals
+Complex editing (rotate/reorder/delete pages), OCR, batch processing.
+
+### Constraints
+Pure browser using pdf-lib, <100MB file size, Chrome/Safari/Firefox latest versions.
+
+---
+
+## 1) Requirements
+
+### 1.1 Functional (expected)
+
+- [ ] BTB-026-REQ-001 (Functional / Expected): User uploads double-page PDF → tool cuts each page vertically in half → generates single-page PDF → direct download
+- [ ] BTB-026-REQ-002 (Functional / Expected): Support preview of original PDF and split result
+
+### 1.2 Functional (forbidden)
+
+- [ ] BTB-026-REQ-003 (Functional / Forbidden): Split PDF must not lose any original content (no cropping overflow)
+- [ ] BTB-026-REQ-004 (Functional / Forbidden): Must not automatically add page numbers/watermarks/other content
+
+### 1.3 Non-functional
+
+- [ ] BTB-026-REQ-005 (Non-functional): Split 100-page PDF < 10s (Chrome)
+- [ ] BTB-026-REQ-006 (Non-functional): Reject >100MB PDFs with user-friendly message (browser memory limit)
+
+### 1.4 Out of scope (optional)
+
+- [ ] BTB-026-REQ-007 (Out of scope): Complex layout detection (title/cover pages)
+- [ ] BTB-026-REQ-008 (Out of scope): Batch processing multiple PDFs
+
+---
+
+## 2) Acceptance criteria
+
+- [ ] BTB-026-AC-001 (maps to BTB-026-REQ-001): Upload sample double-page PDF → click Split → download single-page PDF → verify page count doubles, content intact
+- [ ] BTB-026-AC-002 (maps to BTB-026-REQ-002): Preview shows original PDF and split result
+- [ ] BTB-026-AC-003 (maps to BTB-026-REQ-005): Performance test: 100-page PDF splits < 10s
+
+---
+
+## 3) Test plan
+
+- Unit: pdf-lib operations (split logic, page extraction)
+- E2E: Upload sample PDF → click Split → download verification (page count/content)
+
+---
+
+## 4) Rollout / rollback
+
+- Rollout: Direct deploy (pure new tool)
+- Rollback: Delete tool route/component
+
+---
+
+## 5) Open questions
+
+- Q1: Does user need custom split point (default middle)?
+ Answer: No custom split point needed now. Can extend later.
diff --git a/e2e/fixtures/test-double-page.pdf b/e2e/fixtures/test-double-page.pdf
new file mode 100644
index 0000000..0b02525
Binary files /dev/null and b/e2e/fixtures/test-double-page.pdf differ
diff --git a/e2e/pdf-page-splitter.spec.ts b/e2e/pdf-page-splitter.spec.ts
new file mode 100644
index 0000000..557bec2
--- /dev/null
+++ b/e2e/pdf-page-splitter.spec.ts
@@ -0,0 +1,92 @@
+import { test, expect } from '@playwright/test';
+import { PDFDocument } from 'pdf-lib';
+import { writeFileSync, mkdirSync, readFileSync } from 'fs';
+import { join } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url));
+const FIXTURE_DIR = join(__dirname, 'fixtures');
+const FIXTURE_PATH = join(FIXTURE_DIR, 'test-double-page.pdf');
+
+test.beforeAll(async () => {
+ mkdirSync(FIXTURE_DIR, { recursive: true });
+ const doc = await PDFDocument.create();
+ // Draw a minimal rectangle on each page so the pages have a Contents stream.
+ // pdf-lib requires a Contents entry to embed pages into another document.
+ for (let i = 0; i < 2; i++) {
+ const p = doc.addPage([595, 842]);
+ p.drawRectangle({ x: 10, y: 10, width: 100, height: 50 });
+ }
+ const bytes = await doc.save();
+ writeFileSync(FIXTURE_PATH, bytes);
+});
+
+test('pdf-page-splitter tool page loads', async ({ page }) => {
+ await page.goto('/#/tools/pdf-page-splitter');
+ await expect(page.getByRole('button', { name: 'Split Pages' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Split Pages' })).toBeDisabled();
+ await expect(page.getByText('Click or drag a PDF file here')).toBeVisible();
+});
+
+test('split button becomes enabled after file selection', async ({ page }) => {
+ await page.goto('/#/tools/pdf-page-splitter');
+ await page.locator('input[type="file"]').setInputFiles(FIXTURE_PATH);
+ await expect(page.getByRole('button', { name: 'Split Pages' })).toBeEnabled();
+ await expect(page.getByText('test-double-page.pdf')).toBeVisible();
+});
+
+test('full split flow: upload → split → success message shown', async ({ page }) => {
+ await page.goto('/#/tools/pdf-page-splitter');
+ await page.locator('input[type="file"]').setInputFiles(FIXTURE_PATH);
+ await page.getByRole('button', { name: 'Split Pages' }).click();
+
+ // Wait for processing to complete — success message confirms download was triggered
+ await expect(page.getByRole('status')).toContainText('Done!', { timeout: 15_000 });
+ await expect(page.getByRole('status')).toContainText('2 pages → 4 pages');
+});
+
+test('drop zone accepts dragged PDF file', async ({ page }) => {
+ await page.goto('/#/tools/pdf-page-splitter');
+
+ const fileBuffer = readFileSync(FIXTURE_PATH);
+ const dataTransfer = await page.evaluateHandle(
+ ({ buffer, name }: { buffer: number[]; name: string }) => {
+ const bytes = new Uint8Array(buffer);
+ const file = new File([bytes], name, { type: 'application/pdf' });
+ const dt = new DataTransfer();
+ dt.items.add(file);
+ return dt;
+ },
+ { buffer: Array.from(fileBuffer), name: 'test-double-page.pdf' }
+ );
+
+ await page.locator('.pdf-upload-area').dispatchEvent('drop', { dataTransfer });
+
+ await expect(page.getByRole('button', { name: 'Split Pages' })).toBeEnabled();
+ await expect(page.getByText('test-double-page.pdf')).toBeVisible();
+});
+
+test('shows error for oversized file', async ({ page }) => {
+ await page.goto('/#/tools/pdf-page-splitter');
+
+ // Wait for the file input to be present in the DOM
+ await page.waitForSelector('input[type="file"]', { state: 'attached' });
+
+ // Inject a File object with inflated size to trigger the >100 MB validation
+ await page.evaluate(() => {
+ const input = document.querySelector('.pdf-upload-input') as HTMLInputElement;
+ if (!input) throw new Error('file input not found');
+ const bigBytes = new Uint8Array(10);
+ const blob = new Blob([bigBytes], { type: 'application/pdf' });
+ const oversizedFile = new File([blob], 'huge.pdf', { type: 'application/pdf' });
+ Object.defineProperty(oversizedFile, 'size', { value: 101 * 1024 * 1024 });
+ const dt = new DataTransfer();
+ dt.items.add(oversizedFile);
+ input.files = dt.files;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+
+ await expect(page.getByRole('button', { name: 'Split Pages' })).toBeEnabled();
+ await page.getByRole('button', { name: 'Split Pages' }).click();
+ await expect(page.getByRole('alert')).toContainText('too large');
+});
diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts
index e5249f9..700ad1d 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(16);
});
test('search: result count shown while filtering', async ({ page }) => {
diff --git a/package-lock.json b/package-lock.json
index 4885a5a..b502dbe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"codemirror": "^6.0.2",
"jsonpath-plus": "^10.4.0",
"papaparse": "^5.5.3",
+ "pdf-lib": "^1.17.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
@@ -1408,6 +1409,24 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
+ "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",
@@ -3626,6 +3645,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 +3710,18 @@
"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/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4178,6 +4215,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",
diff --git a/package.json b/package.json
index 62ddd85..1af757f 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"codemirror": "^6.0.2",
"jsonpath-plus": "^10.4.0",
"papaparse": "^5.5.3",
+ "pdf-lib": "^1.17.1",
"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..90a17be 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -24,6 +24,7 @@ const MusicBoxDesignerTool = lazy(() => import('./tools/music-box-designer/Music
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 SettingsPage = lazy(() => import('./tools/settings/SettingsPage').then(m => ({ default: m.SettingsPage })));
+const PdfPageSplitterTool = lazy(() => import('./tools/pdf-page-splitter/PdfPageSplitterTool').then(m => ({ default: m.PdfPageSplitterTool })));
function renderTool(id: string) {
switch (id) {
@@ -42,6 +43,7 @@ function renderTool(id: string) {
case 'music-box-designer': return
Tool not found.
; } } diff --git a/src/lib/__tests__/pdfProcessor.test.ts b/src/lib/__tests__/pdfProcessor.test.ts new file mode 100644 index 0000000..1e53dee --- /dev/null +++ b/src/lib/__tests__/pdfProcessor.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { PdfProcessor } from '../pdfProcessor'; + +async function makePdf(pages: Array<{ width: number; height: number }>): Promise+ {error} +
+ )} + + {status === 'done' && result && ( ++ Done! {result.originalPages} page{result.originalPages !== 1 ? 's' : ''} →{' '} + {result.splitPages} pages. Download started. +
+ )} +