DEV Community

monkeymore studio
monkeymore studio

Posted on

Organizing PDF Pages: Reordering, Rotating, and Removing with QPDF WASM

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:

  1. Privacy Risks: Documents uploaded to external servers
  2. Network Delays: Large PDFs take time to transfer
  3. File Size Limits: Server restrictions on upload size
  4. 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
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]]
Enter fullscreen mode Exit fullscreen mode

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;
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)