Introduction
PDF documents often need reorganization - whether it's reordering pages for better flow, rotating misaligned scans, or removing unwanted pages. In this article, we'll explore how to build a pure browser-side PDF organization tool that handles page reordering, rotation, and deletion using the powerful QPDF library compiled to WebAssembly.
Why Browser-Side PDF Organization?
Traditional PDF tools require server uploads, creating several pain points:
- Privacy Risks: Documents uploaded to external servers
- Network Delays: Large PDFs take time to transfer
- File Size Limits: Server restrictions on upload size
- Dependency: Requires constant internet connection
Browser-side processing eliminates these issues:
- Documents stay on the user's device
- Instant processing regardless of file size
- No upload restrictions
- Works offline after initial load
Architecture Overview
Our implementation uses QPDF compiled to WebAssembly for industrial-strength PDF manipulation:
Core Technologies
1. QPDF - Industrial PDF Manipulation
QPDF is a mature C++ library that provides:
- Page manipulation: Extract, merge, reorder, rotate
- Structure preservation: Maintains bookmarks, links, and annotations
- Encryption support: Handles password-protected PDFs
- Stream optimization: Compress and linearize output
2. WebAssembly (WASM)
By compiling QPDF to WASM:
- Native Performance: C++ execution speed in the browser
- Full QPDF Feature Set: All capabilities available client-side
- Sandboxed Security: Isolated execution environment
- Cross-Platform: Works on any device with a modern browser
Data Structures
1. Page Range Type
// types/pdfdata.ts
export type PageRange = [number, number]; // [start, end] inclusive
2. Worker Functions Interface
// hooks/useqpdf.ts
interface WorkerFunctions {
init: () => Promise<void>;
organize: (file: File, range: string) => Promise<ArrayBuffer>;
rotate: (file: File, degrees: number) => Promise<ArrayBuffer>;
remove: (file: File, ...ranges: PageRange[]) => Promise<ArrayBuffer>;
merge: (files: File[]) => Promise<ArrayBuffer>;
protect: (file: File, options: ProtectOptions) => Promise<ArrayBuffer>;
}
Implementation
1. Entry Point - Page Component
// organize/page.tsx
import { type Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { seoConfig } from "../_components/seo-config";
import { Organize } from "@/app/[locale]/_components/qpdf/organize";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const seo =
seoConfig[locale as keyof typeof seoConfig]?.organize ||
seoConfig["en-us"].organize;
return {
title: seo.title,
description: seo.description,
};
}
export default async function Page() {
return <Organize />;
}
2. Main Component - Page Organization
// _components/qpdf/organize.tsx
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { PdfPage } from "../pdfpage";
import { useInputValue } from "@/hooks/useInputValue";
import { useQpdf } from "@/hooks/useqpdf";
import { autoDownloadBlob } from "@/utils/pdf";
export const Organize = () => {
const [files, setFiles] = useState<File[]>([]);
const { value: pages, onChange: onChangePages } = useInputValue<string>("1-z");
const { organize, isLoading } = useQpdf();
const t = useTranslations("Organize");
const mergeInMain = async () => {
if (files.length === 0 || !pages) return;
// Reorder pages using QPDF
const outputFile = await organize(files[0]!, pages);
if (outputFile) {
autoDownloadBlob(new Blob([outputFile]), "organized.pdf");
}
};
const onPdfFiles = (files: File[]) => {
console.log("Files selected");
files.forEach((e) => console.log(e.name));
setFiles(files);
};
return (
<PdfPage
title={t("title")}
onFiles={onPdfFiles}
process={mergeInMain}
processDisabled={isLoading}
>
<div className="flex flex-col gap-4">
<label className="fieldset-legend">
{t("pageRangeLabel")}
</label>
<input
type="text"
className="input input-bordered w-full"
placeholder="1-3,5,7-z"
value={pages}
onChange={onChangePages}
/>
<p className="text-sm text-gray-500">
{t("pageRangeHelp")}
</p>
<div className="text-xs text-gray-400 mt-2">
<p>Examples:</p>
<ul className="list-disc ml-4">
<li>1-3,5,7-z → Pages 1,2,3,5,7 to end</li>
<li>3-1 → Pages 3,2,1 (reverse)</li>
<li>z-1 → Reverse entire document</li>
</ul>
</div>
</div>
</PdfPage>
);
};
3. Page Range Syntax
QPDF supports powerful page selection syntax:
1-3 → Pages 1, 2, 3
1,4,6 → Pages 1, 4, and 6
3-1 → Pages 3, 2, 1 (reverse order)
5-z → Page 5 to last page
z-1 → Last page to first (full reverse)
1-5,8,10-z → Pages 1-5, 8, and 10 to end
4. QPDF Hook - Worker Communication
// hooks/useqpdf.ts
import { useEffect, useRef, useState } from "react";
import * as Comlink from "comlink";
import PdfWorker from "@/lib/pdf.worker.js";
export const useQpdf = () => {
const workerRef = useRef<Comlink.Remote<WorkerFunctions>>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
async function initWorker() {
const worker = new PdfWorker();
workerRef.current = Comlink.wrap<WorkerFunctions>(worker);
await workerRef.current.init(); // Initialize QPDF WASM
}
initWorker();
}, []);
const organize = async (file: File, range: string): Promise<ArrayBuffer | null> => {
if (!workerRef.current) return null;
setIsLoading(true);
try {
const result = await workerRef.current.organize(file, range);
return result;
} finally {
setIsLoading(false);
}
};
return { organize, isLoading };
};
5. Web Worker - QPDF WASM Integration
// lib/pdf.worker.js
import * as Comlink from "comlink";
import qpdfwasm from "@/lib/qpdfwasm";
let qpdf = null;
const obj = {
async init() {
// Initialize QPDF WASM module
qpdf = await qpdfwasm();
},
async organize(file, range) {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Write input file to Emscripten virtual filesystem
qpdf.FS.writeFile(`/input.pdf`, uint8Array);
// Execute QPDF command:
// qpdf input.pdf --pages input.pdf RANGE -- output.pdf
const params = [
"/input.pdf",
"--pages",
"/input.pdf",
range, // e.g., "3,1,5-2" for custom ordering
"--",
"/output.pdf"
];
qpdf.callMain(params);
// Read result from virtual filesystem
const outputFile = qpdf.FS.readFile("/output.pdf");
// Cleanup
qpdf.FS.unlink("/input.pdf");
qpdf.FS.unlink("/output.pdf");
return outputFile.buffer;
},
async rotate(file, rotate) {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
qpdf.FS.writeFile(`/input.pdf`, uint8Array);
// Rotate all pages: --rotate=degrees:1-z
const params = [
"/input.pdf",
`--rotate=${rotate}:1-z`, // e.g., --rotate=90:1-z
"--",
"/output.pdf"
];
qpdf.callMain(params);
const outputFile = qpdf.FS.readFile("/output.pdf");
return outputFile.buffer;
},
async remove(file, ...ranges) {
// Calculate which pages to keep (inverse of ranges to remove)
const keepRanges = removeRanges([1, 10000], ...ranges);
keepRanges[keepRanges.length - 1][1] = "z"; // Use 'z' for last page
const rangeStr = keepRanges.map((e) => {
if (e.length == 1) return e[0] + "";
else return e[0] + "-" + e[1];
}).join(",");
const arrayBuffer = await file.arrayBuffer();
qpdf.FS.writeFile(`/input.pdf`, new Uint8Array(arrayBuffer));
const params = [
"/input.pdf",
"--pages",
"/input.pdf",
rangeStr, // Pages to keep
"--",
"/output.pdf"
];
qpdf.callMain(params);
const outputFile = qpdf.FS.readFile("/output.pdf");
return outputFile.buffer;
}
};
Comlink.expose(obj);
Page Removal Algorithm
The removeRanges function calculates which pages to keep:
// lib/pdf.worker.js
function removeRanges(mainRange, ...excludeRanges) {
const [start, end] = mainRange;
const excludeSet = new Set();
// Collect all pages to exclude
excludeRanges.forEach(([s, e]) => {
for (let i = s; i <= e; i++) excludeSet.add(i);
});
// Collect remaining pages
const remaining = [];
for (let i = start; i <= end; i++) {
if (!excludeSet.has(i)) remaining.push(i);
}
// Convert to compact range format
const result = [];
let currentStart = remaining[0];
let currentEnd = remaining[0];
for (let i = 1; i < remaining.length; i++) {
if (remaining[i] === currentEnd + 1) {
currentEnd = remaining[i]; // Extend current range
} else {
// Save current range and start new one
result.push(currentStart === currentEnd
? [currentStart]
: [currentStart, currentEnd]);
currentStart = remaining[i];
currentEnd = remaining[i];
}
}
// Don't forget the last range
result.push(currentStart === currentEnd
? [currentStart]
: [currentStart, currentEnd]);
return result;
}
// Example: removeRanges([1, 10], [3, 5], [8, 8])
// Returns: [[1, 2], [6, 7], [9, 10]]
Processing Flow
Complete User Flow
Feature Comparison
| Feature | QPDF Command | Example |
|---|---|---|
| Reorder | --pages input.pdf RANGE |
1,3,2 → Pages 1,3,2 |
| Reverse | --pages input.pdf z-1 |
z-1 → Last to first |
| Extract | --pages input.pdf RANGE |
5-10 → Pages 5-10 only |
| Rotate | --rotate=DEGREES:1-z |
+90:1-z → Rotate all 90° |
| Remove | --pages input.pdf KEPT |
1-2,4-z → Remove page 3 |
Drag-and-Drop File Reordering
The ImageSelector component supports visual reordering:
// _components/imageselector.tsx
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setFiles((items) => {
const oldIndex = items.findIndex((f) => f.name == active.id);
const newIndex = items.findIndex((f) => f.name == over.id);
// Reorder array
const newItems = [...items];
newItems.splice(oldIndex, 1);
const oldFile = items[oldIndex];
newItems.splice(newIndex, 0, oldFile!);
return newItems;
});
}
};
Technical Highlights
1. Virtual Filesystem
QPDF operates on files, so we use Emscripten's FS API:
// Write file to virtual filesystem
qpdf.FS.writeFile(`/input.pdf`, uint8Array);
// Execute QPDF command
qpdf.callMain(params);
// Read result
const output = qpdf.FS.readFile("/output.pdf");
// Cleanup
qpdf.FS.unlink("/input.pdf");
2. Command-Line Interface
QPDF WASM exposes the same CLI interface as the desktop version:
# Desktop
qpdf input.pdf --pages input.pdf 3,1,2 -- output.pdf
# WASM (via callMain)
qpdf.callMain(["/input.pdf", "--pages", "/input.pdf", "3,1,2", "--", "/output.pdf"])
3. Range Compression
The removeRanges algorithm compresses page lists efficiently:
// Input: [1, 2, 3, 6, 7, 10]
// Output: [[1, 3], [6, 7], [10]]
// QPDF syntax: "1-3,6-7,10"
Browser Compatibility
Requirements:
- WebAssembly - Supported in all modern browsers
- Web Workers - For background processing
- File API - For reading user files
- ES6+ - Modern JavaScript features
Supported browsers: Chrome 57+, Firefox 52+, Safari 11+, Edge 16+
Conclusion
Building a browser-side PDF organization tool with QPDF WASM demonstrates the power of bringing native C++ libraries to the web. By combining:
- QPDF for industrial-strength PDF manipulation
- WebAssembly for native performance
- Web Workers for UI responsiveness
- Comlink for seamless worker communication
We've created a tool that offers:
- Complete privacy - Documents never leave the device
- Native performance - C++ execution speed
- Rich functionality - Full QPDF feature set
- Flexible syntax - Powerful page range expressions
- Cross-platform - Works on any device
The ability to reorder, rotate, and remove pages with simple text commands makes PDF organization accessible and efficient.
Need to reorganize your PDF pages? Try our free online tool at Free Online PDF Tools - reorder, rotate, and remove pages with powerful QPDF technology, all in your browser with complete privacy!



Top comments (0)