DEV Community

monkeymore studio
monkeymore studio

Posted on

Building a Browser-Based PDF Rotation Tool with QPDF WebAssembly

In this article, we'll explore how to implement a pure client-side PDF rotation tool that runs entirely in the browser. This tool can rotate PDF pages by any angle (90°, 180°, 270°) in both clockwise and counterclockwise directions.

Why Browser-Based PDF Rotation?

Traditional PDF rotation typically requires:

  • Uploading files to a server
  • Backend processing
  • Downloading the result

Browser-based processing solves all these issues:

  • ✅ Files never leave your computer - complete privacy
  • ✅ Instant processing with no network delays
  • ✅ Works offline after initial load
  • ✅ Zero server costs
  • ✅ No file size limits imposed by server

The Challenge: PDF Page Rotation

PDF rotation is more complex than simple image rotation because:

  • PDFs contain vector graphics, text, and metadata
  • Rotation must preserve text searchability and selectability
  • Page dimensions may need to swap (width ↔ height for 90°/270°)
  • Annotations and form fields must rotate correctly

Architecture Overview

This tool uses the same architecture as other QPDF-based features:

Key Data Structures

Rotation Options

// Rotation values mapped to degrees
const rotationOptions = [
  { name: "clockwise_0",       degree: 0,   title: "No Rotation" },
  { name: "clockwise_90",      degree: 90,  title: "Clockwise 90°" },
  { name: "clockwise_180",     degree: 180, title: "Clockwise 180°" },
  { name: "clockwise_270",     degree: 270, title: "Clockwise 270°" },
  { name: "counterclockwise_90",  degree: -90,  title: "Counterclockwise 90°" },
  { name: "counterclockwise_180", degree: -180, title: "Counterclockwise 180°" },
  { name: "counterclockwise_270", degree: -270, title: "Counterclockwise 270°" },
];
Enter fullscreen mode Exit fullscreen mode

WorkerFunctions Interface

// hooks/useqpdf.ts
interface WorkerFunctions {
  init: () => Promise<void>;
  rotate: (file: File, rotate: number) => Promise<ArrayBuffer | null>;
  // ... other operations
}
Enter fullscreen mode Exit fullscreen mode

Implementation Deep Dive

1. User Interface Component

The rotate component provides an intuitive radio button interface:

// app/[locale]/_components/qpdf/rotate.tsx
export const Rotate = () => {
  const [files, setFiles] = useState<File[]>([]);
  const [degree, setDegree] = useState(0);
  const t = useTranslations("Rotate");
  const { rotate } = useQpdf();

  const mergeInMain = async () => {
    console.log("Rotating PDF:", files[0]?.name);

    // Call rotate function with selected degree
    const outputFile = await rotate(files[0]!, degree);

    if (outputFile) {
      autoDownloadBlob(new Blob([outputFile]), "rotated.pdf");
    }
  };

  return (
    <PdfPage
      process={mergeInMain}
      onFiles={onPdfFiles}
      title={t("title")}
      desp={t("desp")}
    >
      <div>
        <Radio
          defaultValue="clockwise_0"
          values={[
            { name: "clockwise_0", title: t("clockwise_0") },
            { name: "clockwise_90", title: t("clockwise_90") },
            { name: "clockwise_180", title: t("clockwise_180") },
            { name: "clockwise_270", title: t("clockwise_270") },
            { name: "counterclockwise_90", title: t("counterclockwise_90") },
            { name: "counterclockwise_180", title: t("counterclockwise_180") },
            { name: "counterclockwise_270", title: t("counterclockwise_270") },
          ]}
          onValueChange={(e) => {
            switch (e) {
              case "clockwise_0": {
                setDegree(0);
                break;
              }
              case "clockwise_90": {
                setDegree(90);
                break;
              }
              case "clockwise_180": {
                setDegree(180);
                break;
              }
              case "clockwise_270": {
                setDegree(270);
                break;
              }
              case "counterclockwise_90": {
                setDegree(-90);
                break;
              }
              case "counterclockwise_180": {
                setDegree(-180);
                break;
              }
              case "counterclockwise_270": {
                setDegree(-270);
                break;
              }
            }
          }}
        />
      </div>
    </PdfPage>
  );
};
Enter fullscreen mode Exit fullscreen mode

Key features:

  • Radio button group for rotation selection
  • Support for both clockwise and counterclockwise directions
  • Degree values: 0, ±90, ±180, ±270
  • Intuitive mapping from UI selection to rotation degrees

2. Radio Button Component

A reusable radio button component for rotation selection:

// app/[locale]/_components/radio.tsx
export type RadioProps = {
  values: { name: string; title: string }[];
  defaultValue: string;
  onValueChange: (value: string) => void;
};

export function Radio({ values, defaultValue, onValueChange }: RadioProps) {
  const { value, onChange: handleSplitChange } =
    useInputValue<string>(defaultValue);

  useEffect(() => {
    onValueChange(value);
  }, [value, onValueChange]);

  return (
    <fieldset className="fieldset bg-base-100">
      {values.map((e, i) => (
        <label className="label" key={i}>
          <input
            type="radio"
            name="splitType"  // Same name for mutual exclusion
            value={e.name}
            className="radio"
            checked={value === e.name}
            onChange={handleSplitChange}
          />
          {e.title}
        </label>
      ))}
    </fieldset>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Worker Management with Comlink

The useQpdf hook manages the Web Worker:

// hooks/useqpdf.ts
export const useQpdf = () => {
  const workerRef = useRef<Comlink.Remote<WorkerFunctions>>(null);

  useEffect(() => {
    async function initWorker() {
      if (workerRef.current) return;
      const worker = new PdfWorker();

      worker.onerror = (error) => {
        console.error("Worker error:", error);
      };

      workerRef.current = Comlink.wrap<WorkerFunctions>(worker);
      await workerRef.current.init();
      return () => worker.terminate();
    }

    initWorker().catch(() => { return; });
  }, []);

  const rotate = async (
    file: File,
    rotate: number,
  ): Promise<ArrayBuffer | null> => {
    console.log("Calling rotate in main thread", workerRef.current);

    if (!workerRef.current) return null;

    const r = await workerRef.current.rotate(file, rotate);
    return r;
  };

  return { rotate };
};
Enter fullscreen mode Exit fullscreen mode

4. QPDF Rotation Implementation in Web Worker

The actual rotation happens in the Web Worker using QPDF:

// hooks/pdf.worker.js
async rotate(file, rotate) {
  const arrayBuffer = await file.arrayBuffer();
  const uint8Array = new Uint8Array(arrayBuffer);

  // Write input to virtual filesystem
  qpdf.FS.writeFile(`/input.pdf`, uint8Array);

  // Build QPDF rotation command
  // Format: --rotate=angle:page-range
  const params = [
    "/input.pdf",
    `--rotate=${rotate}:1-z`,  // Rotate all pages (1 to last)
    "--",
    "/output.pdf",
  ];

  console.log("Rotation params:", params);

  // Execute QPDF
  qpdf.callMain(params);

  // Read output from virtual filesystem
  const outputFile = qpdf.FS.readFile("/output.pdf");

  return outputFile;
}
Enter fullscreen mode Exit fullscreen mode

QPDF rotation command:

# Rotate all pages 90 degrees clockwise
qpdf input.pdf --rotate=90:1-z -- output.pdf

# Rotate specific pages (e.g., pages 1-5)
qpdf input.pdf --rotate=180:1-5 -- output.pdf

# Rotate different pages differently
qpdf input.pdf --rotate=90:1-3 --rotate=180:4-6 -- output.pdf
Enter fullscreen mode Exit fullscreen mode

Command syntax:

  • --rotate=angle:page-range
  • angle: Rotation in degrees (positive = clockwise, negative = counterclockwise)
  • page-range: Pages to rotate (e.g., 1-z for all pages, 1-5 for pages 1-5)

5. QPDF WASM Initialization

The QPDF WASM module is initialized with Emscripten:

// lib/qpdfwasm.js
import createModule from "@neslinesli93/qpdf-wasm";

const f = async () => {
  const qpdf = await createModule({
    locateFile: () => "/qpdf.wasm",
    noInitialRun: true,  // Don't run main() immediately
  });
  return qpdf;
};

export default f;
Enter fullscreen mode Exit fullscreen mode

Complete Processing Flow

How QPDF Handles Rotation

QPDF performs rotation by modifying the PDF's rotation dictionary:

PDF Rotation Dictionary

In PDF files, each page has a /Rotate entry in its dictionary:

% PDF page dictionary
<<
  /Type /Page
  /Parent 3 0 R
  /MediaBox [0 0 612 792]
  /Rotate 90    % Rotation angle in degrees
  /Contents 5 0 R
>>
Enter fullscreen mode Exit fullscreen mode

Rotation Values

Rotation Effect
0 No rotation (default)
90 Rotate 90° clockwise
180 Rotate 180° (upside down)
270 Rotate 270° clockwise (or 90° counterclockwise)

What QPDF Does

  1. Parses the PDF structure
  2. Finds all page dictionaries in the page tree
  3. Updates the /Rotate entry for specified pages
  4. Recalculates any dependent structures (optional)
  5. Writes the modified PDF with new rotation values

Key Technical Decisions

1. Why QPDF for Rotation?

QPDF is ideal for rotation because:

  • It modifies the PDF rotation dictionary directly
  • Preserves all PDF features (text, forms, annotations)
  • Handles complex PDF structures correctly
  • Fast and reliable

2. Why Web Workers?

PDF processing benefits from Web Workers:

  • Prevents UI freezing during processing
  • Handles large PDFs without blocking
  • Maintains responsive user interface

3. Clockwise vs Counterclockwise

The component supports both directions:

  • Clockwise: Positive degrees (90, 180, 270)
  • Counterclockwise: Negative degrees (-90, -180, -270)

QPDF accepts both positive and negative rotation values.

User Interface

The component provides a clean radio button interface:

<fieldset className="fieldset bg-base-100">
  <label className="label">
    <input type="radio" name="splitType" value="clockwise_0" className="radio" checked />
    No Rotation
  </label>
  <label className="label">
    <input type="radio" name="splitType" value="clockwise_90" className="radio" />
    Clockwise 90°
  </label>
  <label className="label">
    <input type="radio" name="splitType" value="clockwise_180" className="radio" />
    Clockwise 180°
  </label>
  <label className="label">
    <input type="radio" name="splitType" value="clockwise_270" className="radio" />
    Clockwise 270°
  </label>
  <label className="label">
    <input type="radio" name="splitType" value="counterclockwise_90" className="radio" />
    Counterclockwise 90°
  </label>
  <!-- More options... -->
</fieldset>
Enter fullscreen mode Exit fullscreen mode

Benefits of This Architecture

  1. Privacy First: Files never leave the browser
  2. Fast Processing: QPDF is highly optimized
  3. Preserves Quality: Vector graphics and text remain crisp
  4. Responsive UI: Web Workers prevent blocking
  5. No Server Required: Zero backend infrastructure
  6. Standard Compliant: Uses PDF rotation dictionary correctly

Try It Yourself

Want to rotate your PDF pages without uploading them to a server? Try our free browser-based tool:

Rotate PDF Online →

All processing happens locally in your browser - your files never leave your computer!


Conclusion

Building a browser-based PDF rotation tool demonstrates how QPDF compiled to WebAssembly can handle PDF manipulation tasks efficiently. By using the PDF rotation dictionary, we ensure that rotated documents remain fully functional and editable.

This approach is ideal for:

  • Correcting scanned document orientation
  • Preparing documents for different viewing modes
  • Fixing upside-down or sideways pages
  • Batch processing multiple PDFs

The simple radio button interface makes it easy for users to select the exact rotation they need, while the underlying QPDF engine handles the complex PDF structure modifications reliably.

Top comments (0)