Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/spec/BTB-026-pdf-page-splitter/BTB-026-architecture.md
Original file line number Diff line number Diff line change
@@ -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)
75 changes: 75 additions & 0 deletions docs/spec/BTB-026-pdf-page-splitter/BTB-026-requirements.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added e2e/fixtures/test-double-page.pdf
Binary file not shown.
92 changes: 92 additions & 0 deletions e2e/pdf-page-splitter.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
2 changes: 1 addition & 1 deletion e2e/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
43 changes: 43 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -42,6 +43,7 @@ function renderTool(id: string) {
case 'music-box-designer': return <MusicBoxDesignerTool />;
case 'tuner': return <TunerTool />;
case 'jsonpath': return <JsonPathTool />;
case 'pdf-page-splitter': return <PdfPageSplitterTool />;
default: return <p>Tool not found.</p>;
}
}
Expand Down
Loading
Loading