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 ; case 'tuner': return ; case 'jsonpath': return ; + case 'pdf-page-splitter': return ; default: 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> { + const doc = await PDFDocument.create(); + for (const { width, height } of pages) { + // Draw a 1x1 transparent rectangle to ensure each page has a Contents stream, + // which pdf-lib requires when embedding pages into another document. + const page = doc.addPage([width, height]); + page.drawRectangle({ x: 0, y: 0, width: 1, height: 1, opacity: 0 }); + } + const saved = await doc.save(); + // pdf-lib returns Uint8Array; narrow to ArrayBuffer for File/Blob compatibility. + return new Uint8Array(saved.buffer, saved.byteOffset, saved.byteLength) as unknown as Uint8Array; +} + +describe('PdfProcessor.splitPdf', () => { + it('rejects files larger than 100 MB', async () => { + const bigFile = { + size: 101 * 1024 * 1024, + arrayBuffer: async () => new ArrayBuffer(0), + } as File; + const processor = new PdfProcessor(); + await expect(processor.splitPdf(bigFile)).rejects.toThrow('too large'); + }); + + it('rejects an empty PDF (no pages)', async () => { + const bytes = await makePdf([]); + const file = new File([bytes], 'empty.pdf', { type: 'application/pdf' }); + const processor = new PdfProcessor(); + // An empty PDF should always reject — the exact message depends on whether + // pdf-lib loads 0 pages or a phantom page with no Contents stream. + await expect(processor.splitPdf(file)).rejects.toThrow(); + }); + + it('doubles the page count for a single-page PDF', async () => { + const bytes = await makePdf([{ width: 600, height: 800 }]); + const file = new File([bytes], 'single.pdf', { type: 'application/pdf' }); + const processor = new PdfProcessor(); + const result = await processor.splitPdf(file); + expect(result.originalPages).toBe(1); + expect(result.splitPages).toBe(2); + }); + + it('doubles the page count for a multi-page PDF', async () => { + const bytes = await makePdf([ + { width: 600, height: 800 }, + { width: 400, height: 600 }, + ]); + const file = new File([bytes], 'multi.pdf', { type: 'application/pdf' }); + const processor = new PdfProcessor(); + const result = await processor.splitPdf(file); + expect(result.originalPages).toBe(2); + expect(result.splitPages).toBe(4); + }); + + it('produces pages with half the original width', async () => { + const bytes = await makePdf([{ width: 600, height: 800 }]); + const file = new File([bytes], 'test.pdf', { type: 'application/pdf' }); + const processor = new PdfProcessor(); + const result = await processor.splitPdf(file); + + const outDoc = await PDFDocument.load(result.splitPdfBytes); + expect(outDoc.getPageCount()).toBe(2); + const pages = outDoc.getPages(); + expect(pages[0].getWidth()).toBeCloseTo(300); + expect(pages[0].getHeight()).toBeCloseTo(800); + expect(pages[1].getWidth()).toBeCloseTo(300); + expect(pages[1].getHeight()).toBeCloseTo(800); + }); + + it('preserves original height on all split pages', async () => { + const bytes = await makePdf([ + { width: 500, height: 700 }, + { width: 400, height: 600 }, + ]); + const file = new File([bytes], 'two-pages.pdf', { type: 'application/pdf' }); + const processor = new PdfProcessor(); + const result = await processor.splitPdf(file); + + const outDoc = await PDFDocument.load(result.splitPdfBytes); + const pages = outDoc.getPages(); + // Pages 0 & 1 from original page 1 (500x700) + expect(pages[0].getWidth()).toBeCloseTo(250); + expect(pages[0].getHeight()).toBeCloseTo(700); + expect(pages[1].getWidth()).toBeCloseTo(250); + expect(pages[1].getHeight()).toBeCloseTo(700); + // Pages 2 & 3 from original page 2 (400x600) + expect(pages[2].getWidth()).toBeCloseTo(200); + expect(pages[2].getHeight()).toBeCloseTo(600); + expect(pages[3].getWidth()).toBeCloseTo(200); + expect(pages[3].getHeight()).toBeCloseTo(600); + }); + + it('returns a valid PDF byte array', async () => { + const bytes = await makePdf([{ width: 595, height: 842 }]); + const file = new File([bytes], 'a4.pdf', { type: 'application/pdf' }); + const processor = new PdfProcessor(); + const result = await processor.splitPdf(file); + expect(result.splitPdfBytes).toBeInstanceOf(Uint8Array); + expect(result.splitPdfBytes.length).toBeGreaterThan(0); + // Check PDF magic bytes + const header = new TextDecoder().decode(result.splitPdfBytes.slice(0, 4)); + expect(header).toBe('%PDF'); + }); +}); diff --git a/src/lib/pdfProcessor.ts b/src/lib/pdfProcessor.ts new file mode 100644 index 0000000..4943070 --- /dev/null +++ b/src/lib/pdfProcessor.ts @@ -0,0 +1,56 @@ +import { PDFDocument } from 'pdf-lib'; + +export interface SplitResult { + originalPages: number; + splitPages: number; + splitPdfBytes: Uint8Array; +} + +const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB + +export class PdfProcessor { + async splitPdf(file: File): Promise { + if (file.size > MAX_FILE_SIZE) { + throw new Error('File is too large. Please upload a PDF under 100 MB.'); + } + + const arrayBuffer = await file.arrayBuffer(); + const srcDoc = await PDFDocument.load(arrayBuffer); + const originalPages = srcDoc.getPageCount(); + + if (originalPages === 0) { + throw new Error('The uploaded PDF has no pages.'); + } + + const outDoc = await PDFDocument.create(); + const pages = srcDoc.getPages(); + + for (const page of pages) { + const { width, height } = page.getSize(); + const halfW = width / 2; + + // Left half + const [leftEmbed] = await outDoc.embedPages([page], [ + { left: 0, bottom: 0, right: halfW, top: height }, + ]); + const leftPage = outDoc.addPage([halfW, height]); + leftPage.drawPage(leftEmbed, { x: 0, y: 0, width: halfW, height }); + + // Right half + const [rightEmbed] = await outDoc.embedPages([page], [ + { left: halfW, bottom: 0, right: width, top: height }, + ]); + const rightPage = outDoc.addPage([halfW, height]); + rightPage.drawPage(rightEmbed, { x: 0, y: 0, width: halfW, height }); + } + + // pdf-lib returns Uint8Array; narrow to ArrayBuffer for Blob/File compatibility. + const saved = await outDoc.save(); + const splitPdfBytes = new Uint8Array(saved.buffer, saved.byteOffset, saved.byteLength) as unknown as Uint8Array; + return { + originalPages, + splitPages: originalPages * 2, + splitPdfBytes, + }; + } +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 8cc4280..af3dcbe 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -10,7 +10,7 @@ export interface ToolMeta { name: string; description: string; icon: string; - category: 'text' | 'data' | 'color' | 'math' | 'web' | 'misc' | 'music'; + category: 'text' | 'data' | 'color' | 'math' | 'web' | 'misc' | 'music' | 'documents'; status: 'planned' | 'wip' | 'ready'; } @@ -135,6 +135,14 @@ export const TOOLS: ToolMeta[] = [ category: 'data', status: 'ready', }, + { + id: 'pdf-page-splitter', + name: 'PDF Page Splitter', + description: 'Split double-page PDF spreads into individual pages by cutting each page vertically in half.', + icon: '📄', + category: 'documents', + status: 'ready', + }, ]; export const getToolById = (id: string): ToolMeta | undefined => diff --git a/src/tools/pdf-page-splitter/PdfPageSplitterTool.css b/src/tools/pdf-page-splitter/PdfPageSplitterTool.css new file mode 100644 index 0000000..a9e87d4 --- /dev/null +++ b/src/tools/pdf-page-splitter/PdfPageSplitterTool.css @@ -0,0 +1,40 @@ +.pdf-upload-area { + display: flex; + align-items: center; + justify-content: center; + min-height: 100px; + border: 2px dashed var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + cursor: pointer; + background: var(--color-surface); + transition: border-color var(--transition-fast), background var(--transition-fast); + text-align: center; +} + +.pdf-upload-area:hover, +.pdf-upload-area:focus-visible, +.pdf-upload-area--dragging { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 5%, var(--color-surface)); + outline: none; +} + +.pdf-upload-input { + display: none; +} + +.pdf-upload-placeholder { + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.pdf-upload-filename { + font-size: var(--text-sm); + color: var(--color-text); + word-break: break-all; +} + +.pdf-upload-filesize { + color: var(--color-text-muted); +} diff --git a/src/tools/pdf-page-splitter/PdfPageSplitterTool.tsx b/src/tools/pdf-page-splitter/PdfPageSplitterTool.tsx new file mode 100644 index 0000000..b2b4b87 --- /dev/null +++ b/src/tools/pdf-page-splitter/PdfPageSplitterTool.tsx @@ -0,0 +1,134 @@ +import { useRef, useState } from 'react'; +import '../tools.css'; +import './PdfPageSplitterTool.css'; +import { PdfProcessor } from '../../lib/pdfProcessor'; + +type Status = 'idle' | 'processing' | 'done' | 'error'; + +function formatFileSize(bytes: number): string { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function PdfPageSplitterTool() { + const [file, setFile] = useState(null); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [result, setResult] = useState<{ originalPages: number; splitPages: number } | null>(null); + const [dragging, setDragging] = useState(false); + const inputRef = useRef(null); + + function selectFile(f: File | null) { + setFile(f); + setStatus('idle'); + setError(''); + setResult(null); + } + + function handleFileChange(e: React.ChangeEvent) { + selectFile(e.target.files?.[0] ?? null); + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + setDragging(true); + } + + function handleDragLeave() { + setDragging(false); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setDragging(false); + const f = e.dataTransfer.files[0] ?? null; + selectFile(f); + } + + async function handleSplit() { + if (!file) return; + setStatus('processing'); + setError(''); + setResult(null); + + try { + const processor = new PdfProcessor(); + const splitResult = await processor.splitPdf(file); + + const blob = new Blob([splitResult.splitPdfBytes], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = file.name.replace(/\.pdf$/i, '-split.pdf'); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setResult({ originalPages: splitResult.originalPages, splitPages: splitResult.splitPages }); + setStatus('done'); + } catch (err) { + setError(err instanceof Error ? err.message : 'An unexpected error occurred.'); + setStatus('error'); + } + } + + return ( +
+
+ +
inputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()} + aria-label="Upload PDF file" + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + + {file ? ( + + {file.name} ({formatFileSize(file.size)}) + + ) : ( + Click or drag a PDF file here + )} +
+
+ +
+ +
+ + {status === 'error' && ( +

+ {error} +

+ )} + + {status === 'done' && result && ( +

+ Done! {result.originalPages} page{result.originalPages !== 1 ? 's' : ''} →{' '} + {result.splitPages} pages. Download started. +

+ )} +
+ ); +}